// Package sshconf parses ~/.ssh/config (following Include directives) into a // flat list of connectable Host entries. It is strictly read-only: nothing in // this package ever writes to the ssh config tree. // // A small hand-written parser is used instead of a third-party library so that // two things the ssh_config format does not natively support are available: // - the "# tag: a, b" comment convention placed above a Host block, and // - full control over Include expansion order for stable, fast startup. package sshconf import ( "bufio" "io" "os" "path/filepath" "sort" "strings" ) // Host is a single connectable ssh alias with the preview fields dial surfaces. // It intentionally holds only what the picker needs; connecting is done by // exec'ing `ssh ` so the real ssh client re-resolves everything. type Host struct { Alias string HostName string User string Port string ProxyJump string Tags []string Source string // config file this entry was declared in } // maxIncludeDepth guards against pathological or cyclic Include chains. const maxIncludeDepth = 16 // DefaultConfigPath returns ~/.ssh/config for the current user. func DefaultConfigPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".ssh", "config"), nil } // Parse reads the config at path, follows Include directives, and returns the // concrete hosts in declaration order. A missing top-level config is not an // error: it yields an empty list so a fresh machine still launches cleanly. func Parse(path string) ([]Host, error) { sshDir := filepath.Dir(path) p := &parser{sshDir: sshDir, seen: map[string]bool{}} if err := p.parseFile(path, 0); err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } out := make([]Host, len(p.hosts)) for i, h := range p.hosts { out[i] = *h } return out, nil } type parser struct { sshDir string seen map[string]bool // include cycle guard, by absolute path hosts []*Host // pointers so block updates stay valid across appends } func (p *parser) parseFile(path string, depth int) error { if depth > maxIncludeDepth { return nil } abs, err := filepath.Abs(path) if err != nil { abs = path } if p.seen[abs] { return nil // already parsed; avoid include cycles } p.seen[abs] = true f, err := os.Open(path) if err != nil { return err } defer f.Close() return p.parseReader(f, path, depth) } func (p *parser) parseReader(r io.Reader, source string, depth int) error { sc := bufio.NewScanner(r) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) var pendingTags []string // tags from a "# tag:" comment awaiting the next Host var block []*Host // all aliases in the current Host block // applyKV sets a keyword/value pair on every host in the current block. applyKV := func(keyword, value string) { for _, h := range block { switch strings.ToLower(keyword) { case "hostname": h.HostName = value case "user": h.User = value case "port": h.Port = value case "proxyjump": h.ProxyJump = value } } } for sc.Scan() { raw := sc.Text() line := strings.TrimSpace(raw) if line == "" { continue } if strings.HasPrefix(line, "#") { if tags, ok := parseTagComment(line); ok { pendingTags = append(pendingTags, tags...) } continue } keyword, value := splitKeyword(line) if keyword == "" { continue } switch strings.ToLower(keyword) { case "host": block = nil for _, alias := range strings.Fields(value) { if isPattern(alias) { continue // wildcard/negated patterns aren't connect targets } h := &Host{Alias: alias, Source: source, Tags: append([]string(nil), pendingTags...)} p.hosts = append(p.hosts, h) block = append(block, h) } pendingTags = nil case "include": // Include is resolved relative to ~/.ssh and may contain globs. for _, pat := range strings.Fields(value) { for _, inc := range p.expandInclude(pat) { _ = p.parseFile(inc, depth+1) } } pendingTags = nil case "match": // Match blocks are conditional; dial does not evaluate conditions. // End the current block so stray keywords don't attach to a host. block = nil pendingTags = nil default: if len(block) > 0 { applyKV(keyword, value) } } } return sc.Err() } // expandInclude resolves an Include pattern to concrete file paths. Relative // patterns are anchored at ~/.ssh, matching OpenSSH behaviour. func (p *parser) expandInclude(pattern string) []string { pattern = expandTilde(pattern) if !filepath.IsAbs(pattern) { pattern = filepath.Join(p.sshDir, pattern) } matches, err := filepath.Glob(pattern) if err != nil || len(matches) == 0 { return nil } sort.Strings(matches) // deterministic order across machines // Filter out directories; only include regular files. var files []string for _, m := range matches { if fi, err := os.Stat(m); err == nil && !fi.IsDir() { files = append(files, m) } } return files } // parseTagComment recognises the "# tag: prod, riedel" convention and returns // the individual tags. It is lenient about spacing and the tag/tags spelling. func parseTagComment(line string) ([]string, bool) { body := strings.TrimSpace(strings.TrimPrefix(line, "#")) lower := strings.ToLower(body) var rest string switch { case strings.HasPrefix(lower, "tags:"): rest = body[len("tags:"):] case strings.HasPrefix(lower, "tag:"): rest = body[len("tag:"):] default: return nil, false } var tags []string for _, t := range strings.Split(rest, ",") { if t = strings.TrimSpace(t); t != "" { tags = append(tags, t) } } return tags, len(tags) > 0 } // splitKeyword splits an ssh_config line into keyword and value. ssh_config // allows "Key Value" and "Key = Value"; both are handled here. func splitKeyword(line string) (string, string) { // Strip trailing inline comments only when clearly separated. if i := strings.Index(line, " #"); i >= 0 { // keep '#' inside values rare; safe to drop trailing comment line = strings.TrimSpace(line[:i]) } if eq := strings.IndexByte(line, '='); eq >= 0 && !strings.ContainsAny(line[:eq], " \t") { return strings.TrimSpace(line[:eq]), unquote(strings.TrimSpace(line[eq+1:])) } fields := strings.Fields(line) if len(fields) == 0 { return "", "" } if len(fields) == 1 { return fields[0], "" } // Support "Key = Value" where '=' is its own token. if fields[1] == "=" { return fields[0], unquote(strings.TrimSpace(strings.Join(fields[2:], " "))) } return fields[0], unquote(strings.TrimSpace(strings.Join(fields[1:], " "))) } func unquote(s string) string { if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { return s[1 : len(s)-1] } return s } // isPattern reports whether an alias is a wildcard/negation pattern rather than // a concrete connectable host. func isPattern(alias string) bool { return strings.ContainsAny(alias, "*?!") } func expandTilde(path string) string { if path == "~" || strings.HasPrefix(path, "~/") { if home, err := os.UserHomeDir(); err == nil { if path == "~" { return home } return filepath.Join(home, path[2:]) } } return path }