Files
mesh-catalog/modules/ssh-client/cmd/ssh-client-tools/config.go
T
jochen add923c74a ssh-client: the mesh's region first in ~/.ssh/config, its hosts in config.d, tools in Go
The region at the end let earlier Host lines win over the mesh's (research 027/03). A
roster fact cannot be placed at the start, so the region holds one Include of config.d,
and the hosts are config.d/00-mesh, read first. Eight tools; authorized_keys and
known_hosts stay found until the controller holds those facts.
2026-10-04 12:37:44 +02:00

247 lines
7.5 KiB
Go

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
}