Skip to content

Commit

Permalink
Resolve ssh hostname aliases with ssh -G (#84)
Browse files Browse the repository at this point in the history
Going through the `ssh` executable ensures that hostnames get resolved using ssh's exact mechanism and alleviates the need for manually parsing and interpreting ssh configuration files.
  • Loading branch information
mislav committed Nov 2, 2022
1 parent 77a4c4f commit 5d098ef
Show file tree
Hide file tree
Showing 3 changed files with 180 additions and 270 deletions.
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
}

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
}

0 comments on commit 5d098ef

Please sign in to comment.