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.
247 lines
7.5 KiB
Go
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
|
|
}
|