package main // ~/.ssh/config as ssh reads it: first match wins, Include brings files in where it stands, and a // Host or Match line opens a section that runs to the next. Every Host is answered with where it // came from (novox/hq research 027/03): // // - "mesh": the file this module writes, ~/.ssh/config.d/00-mesh (a Host per machine of the mesh), // or a region between the mesh's markers in ~/.ssh/config; // - "drop-in": another file in ~/.ssh/config.d — a module's (it begins with the mesh's header) or // one found there; // - "operator": a line of ~/.ssh/config outside the mesh's region, or a file it includes. import ( "fmt" "os" "path/filepath" "sort" "strings" ) // MeshFile is the file this module writes with the mesh's Host blocks, under ~/.ssh. const MeshFile = "config.d/00-mesh" // MeshHeader is the first line of every file the mesh writes whole. const MeshHeader = "# Generated by the mesh." // Section is one Host or Match section. type Section struct { Kind string `json:"kind"` // "host" or "match" Patterns []string `json:"patterns"` Source string `json:"source"` // the file, relative to ~/.ssh where it is under it Line int `json:"line"` From string `json:"from"` // mesh, drop-in or operator Mesh bool `json:"mesh_written"` Order int `json:"order"` // the order ssh reads it in: an earlier one wins Options map[string]string `json:"options"` } // Parsed is a whole configuration, read as ssh reads it. type Parsed struct { Sections []Section `json:"sections"` // Global is what is set outside any section, in reading order, with where. Global []string `json:"global"` Includes []string `json:"includes"` // Files are every file read, in order. Files []string `json:"files"` Problems []string `json:"problems"` } // Parse reads ~/.ssh/config and what it includes. func Parse(home string) (*Parsed, error) { p := &Parsed{Sections: []Section{}, Global: []string{}, Includes: []string{}, Files: []string{}, Problems: []string{}} main := filepath.Join(home, ".ssh", "config") if _, err := os.Stat(main); err != nil { return nil, fmt.Errorf("there is no %s: %v", main, err) } r := &reader{home: home, out: p} r.file(main, 0, false) return p, nil } type reader struct { home string out *Parsed current *Section } func (r *reader) rel(path string) string { if rel, err := filepath.Rel(filepath.Join(r.home, ".ssh"), path); err == nil && !strings.HasPrefix(rel, "..") { return rel } return path } // from is who a line in a file belongs to. func (r *reader) from(path string, inMeshRegion, meshWritten bool) string { rel := r.rel(path) switch { case rel == MeshFile || inMeshRegion: return "mesh" case strings.HasPrefix(rel, "config.d/"): return "drop-in" case meshWritten: return "mesh" } return "operator" } func (r *reader) file(path string, depth int, fromMesh bool) { if depth > 16 { r.out.Problems = append(r.out.Problems, fmt.Sprintf("%s: included more than 16 deep — ssh refuses that", r.rel(path))) return } raw, err := os.ReadFile(path) if err != nil { r.out.Problems = append(r.out.Problems, fmt.Sprintf("%s: %v", r.rel(path), err)) return } r.out.Files = append(r.out.Files, r.rel(path)) text := string(raw) meshWritten := strings.HasPrefix(text, MeshHeader) inRegion := false // ssh keeps the including file's section across an Include (readconf.c restores it), and an // included file starts outside any section. outer := r.current r.current = nil for n, line := range strings.Split(text, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "# BEGIN mesh ") { inRegion = true continue } if strings.HasPrefix(trimmed, "# END mesh ") { inRegion = false continue } if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } key, args := split(trimmed) from := r.from(path, inRegion || fromMesh, meshWritten) switch strings.ToLower(key) { case "host", "match": kind := strings.ToLower(key) r.out.Sections = append(r.out.Sections, Section{Kind: kind, Patterns: args, Source: r.rel(path), Line: n + 1, From: from, Mesh: meshWritten || from == "mesh", Order: len(r.out.Sections), Options: map[string]string{}}) r.current = &r.out.Sections[len(r.out.Sections)-1] case "include": for _, arg := range args { target := arg if strings.HasPrefix(target, "~/") { target = filepath.Join(r.home, target[2:]) } else if !filepath.IsAbs(target) { target = filepath.Join(r.home, ".ssh", target) } r.out.Includes = append(r.out.Includes, fmt.Sprintf("%s:%d %s", r.rel(path), n+1, arg)) matches, _ := filepath.Glob(target) sort.Strings(matches) for _, m := range matches { if info, err := os.Stat(m); err == nil && info.Mode().IsRegular() { idx := -1 if r.current != nil { idx = r.current.Order } r.file(m, depth+1, from == "mesh" && !strings.HasPrefix(r.rel(m), "config.d/")) if idx >= 0 { r.current = &r.out.Sections[idx] } else { r.current = nil } } } } default: if r.current == nil { r.out.Global = append(r.out.Global, fmt.Sprintf("%s:%d %s", r.rel(path), n+1, trimmed)) } else if _, set := r.current.Options[strings.ToLower(key)]; !set { r.current.Options[strings.ToLower(key)] = strings.Join(args, " ") } } } if outer != nil { r.current = &r.out.Sections[outer.Order] } else { r.current = nil } } // split is one configuration line's keyword and arguments: whitespace or one '=' between, and // double quotes keep spaces in an argument. func split(line string) (string, []string) { var words []string var cur strings.Builder quoted, have := false, false for _, ch := range line { switch { case ch == '"': quoted, have = !quoted, true case !quoted && (ch == ' ' || ch == '\t' || (ch == '=' && len(words) == 0)): if have { words = append(words, cur.String()) cur.Reset() have = false } default: cur.WriteRune(ch) have = true } } if have { words = append(words, cur.String()) } if len(words) == 0 { return "", nil } return words[0], words[1:] } // Duplicates are Host patterns defined in more than one section: the first wins for every option // it sets, which is the trap a predecessor's block above the mesh's fell into. func (p *Parsed) Duplicates() []map[string]any { seen := map[string][]Section{} for _, s := range p.Sections { if s.Kind != "host" { continue } for _, pat := range s.Patterns { if pat == "*" { continue } seen[pat] = append(seen[pat], s) } } out := []map[string]any{} keys := make([]string, 0, len(seen)) for k := range seen { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { if len(seen[k]) < 2 { continue } where := []string{} for _, s := range seen[k] { where = append(where, fmt.Sprintf("%s:%d (%s)", s.Source, s.Line, s.From)) } out = append(out, map[string]any{"host": k, "defined_at": where, "wins": where[0]}) } return out } // MeshHosts are the machines the mesh's own file names, each with the name it is reached by. func (p *Parsed) MeshHosts() []map[string]string { out := []map[string]string{} for _, s := range p.Sections { if s.Kind == "host" && s.Source == MeshFile && len(s.Patterns) > 0 { name := s.Options["hostname"] if name == "" { name = s.Patterns[0] } out = append(out, map[string]string{"host": s.Patterns[0], "hostname": name, "user": s.Options["user"]}) } } return out }