Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolve ssh hostname aliases with ssh -G #84

Merged
merged 3 commits into from Nov 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 8 additions & 12 deletions gh.go
Expand Up @@ -127,7 +127,14 @@ func CurrentRepository() (repo.Repository, error) {
}

translator := ssh.NewTranslator()
translateRemotes(remotes, translator)
for _, r := range remotes {
if r.FetchURL != nil {
r.FetchURL = translator.Translate(r.FetchURL)
}
if r.PushURL != nil {
r.PushURL = translator.Translate(r.PushURL)
}
}

hosts := auth.KnownHosts()

Expand Down Expand Up @@ -169,14 +176,3 @@ func resolveOptions(opts *api.ClientOptions) error {
}
return nil
}

func translateRemotes(remotes git.RemoteSet, translator ssh.Translator) {
for _, r := range remotes {
if r.FetchURL != nil {
r.FetchURL = translator.Translate(r.FetchURL)
}
if r.PushURL != nil {
r.PushURL = translator.Translate(r.PushURL)
}
}
}
205 changes: 63 additions & 142 deletions pkg/ssh/ssh.go
@@ -1,79 +1,39 @@
// Package ssh is a set of types and functions for parsing and
// applying a user's SSH hostname aliases.
// Package ssh resolves local SSH hostname aliases.
package ssh

import (
"bufio"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"os/exec"
"strings"
)
"sync"

var (
configLineRE = regexp.MustCompile(`\A\s*(?P<keyword>[A-Za-z][A-Za-z0-9]*)(?:\s+|\s*=\s*)(?P<argument>.+)`)
tokenRE = regexp.MustCompile(`%[%h]`)
"github.com/cli/safeexec"
)

// Translator is the interface that encapsulates the SSH hostname alias translate method.
type Translator interface {
Translate(*url.URL) *url.URL
}

type config struct {
aliases map[string]string
}

type parser struct {
dir string
cfg config
hosts []string
open func(string) (io.Reader, error)
glob func(string) ([]string, error)
}

// NewTranslator constructs a map of SSH hostname aliases based on user and system configuration files.
// It returns a Translator to apply these mappings.
func NewTranslator() Translator {
configFiles := []string{
"/etc/ssh_config",
"/etc/ssh/ssh_config",
}

p := parser{}
type Translator struct {
cacheMap map[string]string
cacheMu sync.RWMutex
sshPath string
sshPathErr error
sshPathMu sync.Mutex

if sshDir, err := homeDirPath(".ssh"); err == nil {
userConfig := filepath.Join(sshDir, "config")
configFiles = append([]string{userConfig}, configFiles...)
p.dir = filepath.Dir(sshDir)
}

for _, file := range configFiles {
_ = p.read(file)
}

return p.cfg
lookPath func(string) (string, error)
newCommand func(string, ...string) *exec.Cmd
}

func homeDirPath(subdir string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}

newPath := filepath.Join(homeDir, subdir)
return newPath, nil
// NewTranslator initializes a new Translator instance.
func NewTranslator() *Translator {
return &Translator{}
}

// Translate applies applicable SSH hostname aliases to the specified URL and returns the resulting URL.
func (c config) Translate(u *url.URL) *url.URL {
func (t *Translator) Translate(u *url.URL) *url.URL {
if u.Scheme != "ssh" {
return u
}
resolvedHost, ok := c.aliases[u.Hostname()]
if !ok {
resolvedHost, err := t.resolve(u.Hostname())
if err != nil {
return u
}
if strings.EqualFold(resolvedHost, "ssh.github.com") {
Expand All @@ -84,101 +44,62 @@ func (c config) Translate(u *url.URL) *url.URL {
return newURL
}

func (p *parser) read(fileName string) error {
var file io.Reader
if p.open == nil {
f, err := os.Open(fileName)
if err != nil {
return err
}
defer f.Close()
file = f
} else {
var err error
file, err = p.open(fileName)
if err != nil {
return err
}
func (t *Translator) resolve(hostname string) (string, error) {
t.cacheMu.RLock()
cached, cacheFound := t.cacheMap[strings.ToLower(hostname)]
t.cacheMu.RUnlock()
if cacheFound {
return cached, nil
Comment on lines +48 to +52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell this cache is never actually populated in this method. I am wondering about its purpose though. We can rectify this and start populating the cache or I think it would be okay to just allow the users of this package to implement the caching since our implementation is just an in-memory map. What are your thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. If the cache is not populated, that was an oversight on my part.

I do think that an in-memory cache would be beneficial since Translate() is typically used to translate many URLs at once: think of a git repo that has multiple SSH remotes—Translate will be called 2 times per each remote. Since most remotes will be to the same hostname, a built-in cache avoids shelling out to ssh repeatedly.

}

if len(p.hosts) == 0 {
p.hosts = []string{"*"}
}

scanner := bufio.NewScanner(file)
for scanner.Scan() {
m := configLineRE.FindStringSubmatch(scanner.Text())
if len(m) < 3 {
continue
}

keyword, arguments := strings.ToLower(m[1]), m[2]
switch keyword {
case "host":
p.hosts = strings.Fields(arguments)
case "hostname":
for _, host := range p.hosts {
for _, name := range strings.Fields(arguments) {
if p.cfg.aliases == nil {
p.cfg.aliases = make(map[string]string)
}
p.cfg.aliases[host] = expandTokens(name, host)
}
}
case "include":
for _, arg := range strings.Fields(arguments) {
path := p.absolutePath(fileName, arg)

var fileNames []string
if p.glob == nil {
paths, _ := filepath.Glob(path)
for _, p := range paths {
if s, err := os.Stat(p); err == nil && !s.IsDir() {
fileNames = append(fileNames, p)
}
}
} else {
var err error
fileNames, err = p.glob(path)
if err != nil {
continue
}
}

for _, fileName := range fileNames {
_ = p.read(fileName)
}
}
var sshPath string
t.sshPathMu.Lock()
if t.sshPath == "" && t.sshPathErr == nil {
lookPath := t.lookPath
if lookPath == nil {
lookPath = safeexec.LookPath
}
t.sshPath, t.sshPathErr = lookPath("ssh")
}
if t.sshPathErr != nil {
defer t.sshPathMu.Unlock()
return t.sshPath, t.sshPathErr
}
sshPath = t.sshPath
t.sshPathMu.Unlock()

return scanner.Err()
}
t.cacheMu.Lock()
defer t.cacheMu.Unlock()

func (p *parser) absolutePath(parentFile, path string) string {
if filepath.IsAbs(path) || strings.HasPrefix(filepath.ToSlash(path), "/") {
return path
newCommand := t.newCommand
if newCommand == nil {
newCommand = exec.Command
}
sshCmd := newCommand(sshPath, "-G", hostname)
stdout, err := sshCmd.StdoutPipe()
if err != nil {
return "", err
}

if strings.HasPrefix(path, "~") {
return filepath.Join(p.dir, strings.TrimPrefix(path, "~"))
if err := sshCmd.Start(); err != nil {
return "", err
}

if strings.HasPrefix(filepath.ToSlash(parentFile), "/etc/ssh") {
return filepath.Join("/etc/ssh", path)
var resolvedHost string
s := bufio.NewScanner(stdout)
for s.Scan() {
line := s.Text()
parts := strings.SplitN(line, " ", 2)
if len(parts) == 2 && parts[0] == "hostname" {
resolvedHost = parts[1]
}
}

return filepath.Join(p.dir, ".ssh", path)
}
_ = sshCmd.Wait()

func expandTokens(text, host string) string {
return tokenRE.ReplaceAllStringFunc(text, func(match string) string {
switch match {
case "%h":
return host
case "%%":
return "%"
}
return ""
})
if t.cacheMap == nil {
t.cacheMap = map[string]string{}
}
t.cacheMap[strings.ToLower(hostname)] = resolvedHost
return resolvedHost, nil
}