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.
This commit is contained in:
jochen
2026-10-04 12:37:44 +02:00
parent d4a6008bd6
commit add923c74a
10 changed files with 1969 additions and 4 deletions
+109
View File
@@ -0,0 +1,109 @@
# ssh-client
The operator account's `~/.ssh` as a module (novox/hq research 027/03, ADR 0182; to-be 42 phase 1,
item 9). `sshd` is the machine's side.
## What it declares, by ADR 0182's classes
| path | class | how |
|---|---|---|
| `~/.ssh/` | owned | directory, the account's, mode 0700 |
| `~/.ssh/config.d/` | owned | directory, the account's, mode 0700. This is ssh's own drop-in directory: another module that needs a host (a forge, a work bastion) places its own file here |
| `~/.ssh/config.d/00-mesh` | owned | a Host block per other machine of the mesh (a roster fact), regenerated whenever a machine joins, leaves or is renamed. It begins with the mesh's header |
| `~/.ssh/config` | written into, **at the start** | the mesh's region, `# BEGIN mesh ssh-client.config` … `# END …`. It holds one `Include ~/.ssh/config.d/*`. Every line below the region is the operator's, kept byte for byte |
| `~/.ssh/authorized_keys` | found | see *The gap* |
| `~/.ssh/known_hosts` | found | see *The gap* |
| private keys | found | never read, never written. `ssh_client_keys` and `ssh_client_check` read a file's first line to recognise a key, and ask `ssh-keygen` about it |
### Why an include, not Host blocks in the region
ssh takes the first value it finds for each option. Research 027/03 asks that the mesh's hosts come
before anything else in `~/.ssh/config`. Before this change, the region was written at the end: a
predecessor's hosts above it won, for the same machines.
A roster fact cannot be placed at the start. The controller gives facts no `at`, and the host leaves
an existing region where it is. So the region at the start holds only the include, and the hosts
are a whole file in `config.d` that the include reads first. `00-` sorts it before any other drop-in.
ssh restores the including file's section after an `Include` (OpenSSH `readconf.c`). So an operator
line just below the region is global again, as it was at the top of the file. This module's tests
parse the declared region and check it, and `ssh -G` was compared before and after on every machine.
The old region, `ssh-client.fact-ssh-config` at the end of `~/.ssh/config`, is no longer declared, so
the host removes it, and only it, at the first push.
## The gap: authorized keys and known hosts
Research 027/03 gives the mesh a region in `authorized_keys` (the operator's keys as the mesh records
them) and one in `known_hosts` (every machine's host key). **The controller holds neither fact.**
- A roster template sees each machine's name, mesh name, address and account, and nothing else
(mesh-controller `roster.go`).
- No machine fact carries an ssh host key.
- The only key the controller knows for the operator is a sealing key for secrets, not an ssh key.
So both files stay *found*, and this module invents nothing.
**What would close it:**
1. The host reports its machine's public host keys in its profile, and the roster gains a field for
them.
2. The operator's public keys become a mesh record: per account, with a comment saying whose and
where.
Each region is then one more `into: block` resource here. Until then, `ssh_client_check` reads the
files as they are, and `ssh_client_revoke` / `ssh_client_known_host` act on them by hand.
## The one-off clean-up (ADR 0182: the operator removes a predecessor's output, once)
The mesh removes nothing it did not make. After the first push, a machine that had the predecessor's
layout still holds:
1. **The predecessor's `~/.ssh/config.d/mesh`.** Its hosts repeat the mesh's, with the same values,
plus one forge host that no module carries yet. Move that host to your own part of
`~/.ssh/config`, or to a work module's drop-in, then delete the file.
2. **The predecessor's header at the top of `~/.ssh/config`**, now just below the mesh's region: the
comment beginning *"Mesh Host blocks are GENERATED"* and its `Include ~/.ssh/config.d/mesh` line.
3. **A predecessor's block of mesh hosts marked *managed by sshd***, on the machines that still have
one.
4. **Backups beside the live files** (`config.bak-*`, `known_hosts.old`, `removed-*`). `ssh_client_check`
lists them as debris.
Until you do, `ssh_client_hosts` and `ssh_client_check` list the repeated hosts as duplicates. The
mesh's still win, because they are read first.
## Tools
They run as the operator account and never escalate. No answer carries a key: fingerprints only.
| tool | | what |
|---|---|---|
| `ssh_client_hosts` | r | every Host and Match section in the order ssh reads it, each with file, line and source (`mesh`, `drop-in`, `operator`); duplicates; what is set outside any section |
| `ssh_client_resolve` | r | `ssh -G` for one host: what ssh would use, and which sections matched |
| `ssh_client_check` | r | modes of the directory and every file; keys with no passphrase (`ssh-keygen -y -P ""`, output used only to compare with the `.pub`), weak, old, unused, or whose `.pub` is another key's; one key under several names; the mesh's region first with its include; duplicate hosts; `known_hosts` for the mesh's machines, missing or stale (scanned live, 5 s each in parallel, unless `scan` is false); authorized keys without a comment; debris. It says what it did not check |
| `ssh_client_keys` | r | every private key, by its public half: type, size, fingerprint, comment, mode, age, passphrase, whether ssh offers it by itself |
| `ssh_client_authorized` | r | `authorized_keys` by fingerprint, type, size, comment and options |
| `ssh_client_revoke` | a | removes every line with one fingerprint, keeping the file first as `authorized_keys.revoked-<time>`. It refuses the last key (a lockout) and a key in a mesh region (a push would write it back) |
| `ssh_client_known_host` | r/a | known entries against what the host offers now: `matches`, `changed`, `not known` or `unreachable`. With `refresh`, it replaces the host's entries through `ssh-keygen -R` (which keeps `known_hosts.old`) with what was scanned. That trusts whatever answers |
| `ssh_client_test` | r | one batch-mode connection that runs only `true`: the address reached, the method and key that authenticated, or why not and which keys were offered. A key with a passphrase works only through an agent. The runtime has none in its environment, so the tool looks for the account's agent socket under `/run/user/<uid>` and names the one it used, or says there was none |
## Tests
```
go test ./...
```
The tests use a temporary home and a fake runner, with public keys made for the tests only. They cover:
- reading order and source across includes, with an operator line after the include being global
again;
- the declared region parsed as ssh reads it;
- duplicates;
- keys: fingerprints computed as `ssh-keygen -l` does, RSA size, passphrase asked and never prompted,
a mismatched `.pub`;
- `authorized` never printing a key;
- `revoke`: the backup, the mode kept, a lockout refused, a mesh region refused;
- `known_host` matching, changed and refreshed, and an unreachable host never refreshed;
- `test`: success, and failure with the agent named;
- `check`'s findings;
- that the tools served are the manifest's `tools`.
@@ -0,0 +1,844 @@
package main
// The operator account's ~/.ssh, asked and — for one authorized key and one known host — changed.
// The node's tool runtime runs as that account (novox/hq ADR 0175 §4), so nothing here escalates:
// every file it touches is the account's own. Private keys are never read here (keys.go).
import (
"bufio"
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// Client answers about one home's ~/.ssh.
type Client struct {
Home string
UID int
Run Runner
Now func() time.Time
}
// NewClient is the client the bundle serves with: the operator's home as the runtime names it.
func NewClient() *Client {
home := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME"))
if home == "" {
home, _ = os.UserHomeDir()
}
return &Client{Home: home, UID: os.Getuid(), Run: ExecRunner, Now: time.Now}
}
func (c *Client) ssh(name string) string { return filepath.Join(c.Home, ".ssh", name) }
var hostPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.:-]*$`)
// HostArg is a host's name as an argument: never something ssh would read as an option.
func HostArg(s string) (string, error) {
s = strings.TrimSpace(s)
if !hostPattern.MatchString(s) || len(s) > 253 {
return "", fmt.Errorf("%q is not a host name", s)
}
return s, nil
}
// ---- hosts -----------------------------------------------------------------------------------
// Hosts is every Host and Match section with where it came from, in the order ssh reads them.
func (c *Client) Hosts() (map[string]any, error) {
p, err := Parse(c.Home)
if err != nil {
return nil, err
}
return map[string]any{"sections": p.Sections, "global": p.Global, "includes": p.Includes, "files": p.Files,
"duplicates": p.Duplicates(), "problems": p.Problems,
"note": "ssh takes the first value it finds for each option: an earlier section wins over a later one for the same host"}, nil
}
// Resolve is what ssh would use for one host, as `ssh -G` computes it.
func (c *Client) Resolve(ctx context.Context, host string) (map[string]any, error) {
host, err := HostArg(host)
if err != nil {
return nil, err
}
r := c.Run(ctx, nil, "ssh", "-G", host)
if r.Status != 0 || r.Err != "" {
return nil, named("ssh -G", r)
}
keep := map[string]bool{"hostname": true, "user": true, "port": true, "identityfile": true, "proxyjump": true,
"proxycommand": true, "identitiesonly": true, "stricthostkeychecking": true, "userknownhostsfile": true,
"forwardagent": true, "controlmaster": true, "controlpath": true, "addkeystoagent": true, "identityagent": true}
out := map[string]any{"host": host}
for _, l := range strings.Split(r.Stdout, "\n") {
k, v, _ := strings.Cut(strings.TrimSpace(l), " ")
if keep[k] {
if prev, ok := out[k]; ok {
out[k] = fmt.Sprint(prev) + ", " + v
} else {
out[k] = v
}
}
}
if p, err := Parse(c.Home); err == nil {
matched := []string{}
for _, s := range p.Sections {
if s.Kind == "host" && matchesAny(host, s.Patterns) {
matched = append(matched, fmt.Sprintf("%s:%d (%s)", s.Source, s.Line, s.From))
}
}
out["sections_matching"] = matched
}
return out, nil
}
// matchesAny is ssh's Host matching: globs with * and ?, a leading ! negates.
func matchesAny(host string, patterns []string) bool {
hit := false
for _, p := range patterns {
neg := strings.HasPrefix(p, "!")
ok, _ := filepath.Match(strings.TrimPrefix(p, "!"), host)
if ok && neg {
return false
}
hit = hit || ok
}
return hit
}
func named(what string, r Ran) error {
switch {
case r.Err == "ENOENT":
return fmt.Errorf("%s: the program is not installed on this machine", what)
case r.Err != "":
return fmt.Errorf("%s did not answer: %s", what, r.Err)
}
if l := firstLine(r.Stderr + "\n" + r.Stdout); l != "" {
return fmt.Errorf("%s failed (%d): %s", what, r.Status, l)
}
return fmt.Errorf("%s failed with status %d", what, r.Status)
}
// ---- keys --------------------------------------------------------------------------------------
// Key is one private key under ~/.ssh, described by its public half.
type Key struct {
Path string `json:"path"`
Type string `json:"type"`
Bits int `json:"bits"`
Fingerprint string `json:"fingerprint"`
Comment string `json:"comment"`
Mode string `json:"mode"`
AgeDays int `json:"age_days"`
Passphrase string `json:"passphrase"` // "yes", "none" or why it could not be told
OfferedBy string `json:"offered_by"` // "default name", "IdentityFile at …", or ""
Weak string `json:"weak,omitempty"`
PublicMissing bool `json:"public_half_missing,omitempty"`
// PublicMismatch: the .pub beside the key is another key's — ssh offers the private key, and
// whoever installs the .pub into an authorized_keys installs the wrong one.
PublicMismatch string `json:"public_half_mismatch,omitempty"`
}
var notKeys = regexp.MustCompile(`^(config|known_hosts|authorized_keys|environment|rc)(\..*|-.*)?$|\.pub$`)
var defaultKeys = map[string]bool{"id_rsa": true, "id_ecdsa": true, "id_ecdsa_sk": true, "id_ed25519": true, "id_ed25519_sk": true, "id_dsa": true}
// privateKeys are the files directly under ~/.ssh whose first line is a private key's PEM header.
func (c *Client) privateKeys() ([]string, error) {
entries, err := os.ReadDir(c.ssh(""))
if err != nil {
return nil, fmt.Errorf("cannot read %s: %v", c.ssh(""), err)
}
out := []string{}
for _, e := range entries {
if !e.Type().IsRegular() || notKeys.MatchString(e.Name()) {
continue
}
if header(c.ssh(e.Name())) {
out = append(out, c.ssh(e.Name()))
}
}
return out, nil
}
// header reads a file's first line only — never more of a key — and says whether it is a private
// key's.
func header(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
line, _ := bufio.NewReader(f).ReadString('\n')
return strings.HasPrefix(line, "-----BEGIN") && strings.Contains(line, "PRIVATE KEY-----")
}
// Keys is every private key under ~/.ssh with its type, size, fingerprint, age, whether it has a
// passphrase, and whether ssh offers it by itself.
func (c *Client) Keys(ctx context.Context) ([]Key, error) {
paths, err := c.privateKeys()
if err != nil {
return nil, err
}
identity := map[string]string{}
if p, err := Parse(c.Home); err == nil {
for _, s := range p.Sections {
if f := s.Options["identityfile"]; f != "" {
f = strings.Replace(f, "~", c.Home, 1)
if !filepath.IsAbs(f) {
f = c.ssh(f)
}
identity[f] = fmt.Sprintf("IdentityFile at %s:%d", s.Source, s.Line)
}
}
}
keys := []Key{}
for _, path := range paths {
k := Key{Path: path}
if info, err := os.Stat(path); err == nil {
k.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
k.AgeDays = int(c.Now().Sub(info.ModTime()).Hours() / 24)
}
if pub, err := os.ReadFile(path + ".pub"); err == nil {
if pk, err := ParseKeyLine(string(pub)); err == nil {
k.Type, k.Bits, k.Fingerprint, k.Comment, k.Weak = pk.Type, pk.Bits, pk.Fingerprint, pk.Comment, pk.Weak
}
} else {
k.PublicMissing = true
// ssh-keygen reads the public half an OpenSSH private key carries unencrypted.
r := c.Run(ctx, nil, "ssh-keygen", "-l", "-f", path)
if r.Status == 0 {
f := strings.Fields(r.Stdout)
if len(f) >= 2 {
k.Fingerprint = f[1]
k.Type = strings.Trim(f[len(f)-1], "()")
}
}
}
var derived string
k.Passphrase, derived = c.passphrase(ctx, path)
if derived != "" {
if pk, err := ParseKeyLine(derived); err == nil {
if k.Fingerprint != "" && !k.PublicMissing && pk.Fingerprint != k.Fingerprint {
k.PublicMismatch = fmt.Sprintf("the key is %s; its .pub is %s", pk.Fingerprint, k.Fingerprint)
}
if k.Fingerprint == "" || k.PublicMismatch != "" {
k.Type, k.Bits, k.Fingerprint, k.Weak = pk.Type, pk.Bits, pk.Fingerprint, pk.Weak
}
}
}
switch {
case identity[path] != "":
k.OfferedBy = identity[path]
case defaultKeys[filepath.Base(path)]:
k.OfferedBy = "default name: ssh offers it to every host"
}
keys = append(keys, k)
}
return keys, nil
}
// passphrase asks ssh-keygen to derive the public key with an empty passphrase: it succeeds only on
// a key with none. What it prints is the public key — public, and used only to compare with the .pub.
func (c *Client) passphrase(ctx context.Context, path string) (string, string) {
r := c.Run(ctx, nil, "ssh-keygen", "-y", "-P", "", "-f", path)
switch {
case r.Status == 0 && r.Err == "":
return "none", strings.TrimSpace(r.Stdout)
case strings.Contains(r.Stderr, "incorrect passphrase") || strings.Contains(r.Stderr, "passphrase"):
return "yes", ""
case r.Err == "ENOENT":
return "unknown: ssh-keygen is not installed", ""
}
return "unknown: " + firstLine(r.Stderr), ""
}
// ---- authorized_keys -----------------------------------------------------------------------------
// AuthorizedKey is one line of authorized_keys, by fingerprint.
type AuthorizedKey struct {
PublicKey
Line int `json:"line"`
InMesh bool `json:"in_mesh_region,omitempty"`
}
// Authorized is who may log in as this account by key: each line's fingerprint, type, size,
// comment and options — never the key itself.
func (c *Client) Authorized() (map[string]any, error) {
keys, bad, err := c.readAuthorized()
if err != nil {
return nil, err
}
seen := map[string]int{}
dups := []string{}
for _, k := range keys {
seen[k.Fingerprint]++
if seen[k.Fingerprint] == 2 {
dups = append(dups, k.Fingerprint)
}
}
return map[string]any{"file": c.ssh("authorized_keys"), "count": len(keys), "keys": keys, "duplicates": dups, "unreadable_lines": bad}, nil
}
func (c *Client) readAuthorized() ([]AuthorizedKey, []int, error) {
raw, err := os.ReadFile(c.ssh("authorized_keys"))
if err != nil {
if os.IsNotExist(err) {
return []AuthorizedKey{}, []int{}, nil
}
return nil, nil, err
}
keys, bad := []AuthorizedKey{}, []int{}
inMesh := false
for n, line := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(line)
switch {
case strings.HasPrefix(t, "# BEGIN mesh "):
inMesh = true
continue
case strings.HasPrefix(t, "# END mesh "):
inMesh = false
continue
case t == "" || strings.HasPrefix(t, "#"):
continue
}
k, err := ParseKeyLine(t)
if err != nil {
bad = append(bad, n+1)
continue
}
keys = append(keys, AuthorizedKey{PublicKey: k, Line: n + 1, InMesh: inMesh})
}
return keys, bad, nil
}
// Revoke takes every line carrying one key out of authorized_keys, after keeping the file as it
// was beside it. It refuses a key the mesh's region carries (the next push would put it back), a
// fingerprint it does not find, and taking the last key (that would lock the account out of ssh).
func (c *Client) Revoke(fingerprint string) (map[string]any, error) {
fingerprint = strings.TrimSpace(fingerprint)
if !strings.HasPrefix(fingerprint, "SHA256:") {
fingerprint = "SHA256:" + fingerprint
}
path := c.ssh("authorized_keys")
keys, _, err := c.readAuthorized()
if err != nil {
return nil, err
}
drop := map[int]bool{}
var removed []AuthorizedKey
for _, k := range keys {
if k.Fingerprint == fingerprint {
if k.InMesh {
return nil, fmt.Errorf("%s is in the mesh's region of authorized_keys (line %d): the next push writes it back; take it out of the mesh's record instead", fingerprint, k.Line)
}
drop[k.Line] = true
removed = append(removed, k)
}
}
if len(removed) == 0 {
return nil, fmt.Errorf("no key in %s has the fingerprint %s (ssh_client_authorized lists them)", path, fingerprint)
}
if len(removed) == len(keys) {
return nil, fmt.Errorf("%s is the only key that may log in as this account: revoking it would lock ssh out", fingerprint)
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
backup := fmt.Sprintf("%s.revoked-%s", path, c.Now().UTC().Format("20060102T150405Z"))
if err := os.WriteFile(backup, raw, 0o600); err != nil {
return nil, fmt.Errorf("could not keep the file before changing it, so it was left as it is: %v", err)
}
lines := strings.Split(string(raw), "\n")
kept := make([]string, 0, len(lines))
for n, l := range lines {
if !drop[n+1] {
kept = append(kept, l)
}
}
if err := atomicWrite(path, []byte(strings.Join(kept, "\n")), info.Mode().Perm()); err != nil {
return nil, err
}
return map[string]any{"revoked": removed, "file": path, "backup": backup, "remaining": len(keys) - len(removed)}, nil
}
func atomicWrite(path string, data []byte, mode fs.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
// ---- known_hosts -------------------------------------------------------------------------------
// knownFor is what known_hosts holds for one host (port 22, or [host]:port), by fingerprint.
func (c *Client) knownFor(ctx context.Context, host string, port int) ([]PublicKey, error) {
name := host
if port != 22 {
name = fmt.Sprintf("[%s]:%d", host, port)
}
file := c.ssh("known_hosts")
if _, err := os.Stat(file); os.IsNotExist(err) {
return []PublicKey{}, nil
}
r := c.Run(ctx, nil, "ssh-keygen", "-F", name, "-f", file)
if r.Err != "" {
return nil, named("ssh-keygen -F", r)
}
// Exit 1 with nothing printed is "not found".
keys := []PublicKey{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l == "" || strings.HasPrefix(l, "#") {
continue
}
if k, err := ParseKeyLine(l); err == nil {
k.Comment, k.Options = "", ""
keys = append(keys, k)
}
}
return keys, nil
}
// scan asks a host for its keys now.
func (c *Client) scan(ctx context.Context, host string, port int) ([]PublicKey, []string, error) {
r := c.Run(ctx, nil, "ssh-keyscan", "-T", "5", "-p", fmt.Sprint(port), host)
if r.Err != "" {
return nil, nil, named("ssh-keyscan", r)
}
keys, lines := []PublicKey{}, []string{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l == "" || strings.HasPrefix(l, "#") {
continue
}
if k, err := ParseKeyLine(l); err == nil {
k.Comment, k.Options = "", ""
keys = append(keys, k)
lines = append(lines, l)
}
}
if len(keys) == 0 {
return nil, nil, fmt.Errorf("%s:%d gave no host key: %s", host, port, orElse(firstLine(r.Stderr), "nothing answered within 5 s"))
}
return keys, lines, nil
}
func orElse(s, def string) string {
if s == "" {
return def
}
return s
}
// compare says how what is known stands against what the host offers now.
func compare(known, live []PublicKey) string {
if len(known) == 0 {
return "not known: the first connection would ask"
}
byType := map[string]string{}
for _, k := range live {
byType[k.Type] = k.Fingerprint
}
matched := false
for _, k := range known {
fp, offered := byType[k.Type]
if offered && fp != k.Fingerprint {
return "changed: the host offers a different key than known_hosts holds — ssh refuses it until the entry is refreshed"
}
matched = matched || offered
}
if !matched {
return "no common type: known_hosts holds a key of a type the host no longer offers"
}
return "matches"
}
// KnownHost is what known_hosts holds for a host and what the host offers now; with refresh, the
// host's entries are replaced by what it offers (ssh-keygen keeps known_hosts.old). A refresh
// trusts whatever answers now, so it is for a host whose key is known to have changed.
func (c *Client) KnownHost(ctx context.Context, host string, port int, refresh bool) (map[string]any, error) {
host, err := HostArg(host)
if err != nil {
return nil, err
}
if port < 1 || port > 65535 {
return nil, fmt.Errorf("port %d is not a TCP port", port)
}
known, err := c.knownFor(ctx, host, port)
if err != nil {
return nil, err
}
answer := map[string]any{"host": host, "port": port, "known": known}
live, lines, scanErr := c.scan(ctx, host, port)
if scanErr != nil {
answer["offered"] = nil
answer["state"] = "unreachable: " + scanErr.Error()
} else {
answer["offered"] = live
answer["state"] = compare(known, live)
}
if !refresh {
return answer, nil
}
if scanErr != nil {
return nil, fmt.Errorf("not refreshed: %v", scanErr)
}
name := host
if port != 22 {
name = fmt.Sprintf("[%s]:%d", host, port)
}
file := c.ssh("known_hosts")
if len(known) > 0 {
if r := c.Run(ctx, nil, "ssh-keygen", "-R", name, "-f", file); r.Status != 0 || r.Err != "" {
return nil, named("ssh-keygen -R", r)
}
answer["backup"] = file + ".old"
}
f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
defer f.Close()
if _, err := f.WriteString(strings.Join(lines, "\n") + "\n"); err != nil {
return nil, err
}
answer["refreshed"] = true
answer["known"] = live
answer["state"] = "matches"
return answer, nil
}
// ---- test ----------------------------------------------------------------------------------------
// agentSockets are where an agent of the account listens when the runtime's environment names
// none: the keyring's, then a user unit's.
func (c *Client) agentSockets() []string {
run := fmt.Sprintf("/run/user/%d", c.UID)
return []string{run + "/gcr/ssh", run + "/keyring/ssh", run + "/ssh-agent.socket", run + "/openssh_agent"}
}
// Test connects to a host in batch mode — no prompt, nothing run but `true` — and says how it
// authenticated or why it could not.
func (c *Client) Test(ctx context.Context, host string) (map[string]any, error) {
host, err := HostArg(host)
if err != nil {
return nil, err
}
var env []string
agent := os.Getenv("SSH_AUTH_SOCK")
if agent == "" {
for _, s := range c.agentSockets() {
if info, err := os.Stat(s); err == nil && info.Mode()&fs.ModeSocket != 0 {
agent = s
env = []string{"SSH_AUTH_SOCK=" + s}
break
}
}
}
start := c.Now()
r := c.Run(ctx, env, "ssh", "-v", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", host, "true")
answer := map[string]any{"host": host, "elapsed_ms": c.Now().Sub(start).Milliseconds()}
if agent != "" {
answer["agent"] = agent
} else {
answer["agent"] = "none: a key with a passphrase cannot be used from here"
}
if r.Err != "" {
if r.Err == "ENOENT" {
return nil, fmt.Errorf("ssh is not installed on this machine")
}
answer["ok"], answer["why"] = false, r.Err
return answer, nil
}
log := r.Stderr
if m := regexp.MustCompile(`Authenticated to (\S+) \(([^)]*)\) using "([^"]+)"`).FindStringSubmatch(log); m != nil {
answer["ok"], answer["authenticated_to"], answer["address"], answer["method"] = r.Status == 0, m[1], m[2], m[3]
} else {
answer["ok"] = false
}
if m := regexp.MustCompile(`Server accepts key: (\S+) (\S+) (SHA256:\S+)`).FindStringSubmatch(log); m != nil {
answer["key"] = map[string]string{"file": m[1], "type": m[2], "fingerprint": m[3]}
}
if m := regexp.MustCompile(`Connecting to \S+ \[([^\]]+)\] port (\d+)`).FindStringSubmatch(log); m != nil {
answer["connected_to"] = m[1] + ":" + m[2]
}
if answer["ok"] == true {
return answer, nil
}
reasons := []struct{ pattern, why string }{
{"Could not resolve hostname", "the name does not resolve"},
{"Connection refused", "nothing listens for ssh there"},
{"Connection timed out", "no answer within 8 s"},
{"No route to host", "no route to the host"},
{"Host key verification failed", "the host's key is not the one known_hosts holds, or it is not known (ssh_client_known_host)"},
{"REMOTE HOST IDENTIFICATION HAS CHANGED", "the host's key changed (ssh_client_known_host compares and refreshes)"},
{"No ED25519 host key is known", "the host is not in known_hosts (ssh_client_known_host refresh adds it)"},
{"Permission denied", "no key it offered was accepted"},
}
for _, rr := range reasons {
if strings.Contains(log, rr.pattern) {
answer["why"] = rr.why
break
}
}
if _, ok := answer["why"]; !ok {
answer["why"] = orElse(lastMeaningful(log), fmt.Sprintf("ssh exited %d", r.Status))
}
offered := []string{}
for _, m := range regexp.MustCompile(`Offering public key: (\S+)`).FindAllStringSubmatch(log, -1) {
offered = append(offered, m[1])
}
answer["offered"] = offered
if regexp.MustCompile(`(?i)read_passphrase|passphrase`).MatchString(log) || agent == "" {
answer["note"] = "a key protected by a passphrase is offered only through an agent; this ran with " + fmt.Sprint(answer["agent"])
}
return answer, nil
}
func lastMeaningful(log string) string {
ls := strings.Split(strings.TrimSpace(log), "\n")
for i := len(ls) - 1; i >= 0; i-- {
if l := strings.TrimSpace(ls[i]); l != "" && !strings.HasPrefix(l, "debug1:") {
return l
}
}
return ""
}
// ---- check ---------------------------------------------------------------------------------------
// Finding is one thing check found wrong, or worth a look.
type Finding struct {
Severity string `json:"severity"` // "problem" or "note"
Path string `json:"path,omitempty"`
What string `json:"what"`
}
// Check is everything about ~/.ssh worth a person's attention: modes, keys without a passphrase or
// weak or old, the mesh's include, duplicate hosts, stale known_hosts entries for the mesh's
// machines, authorized keys, and debris. It also says what it did not check.
func (c *Client) Check(ctx context.Context, scan bool) (map[string]any, error) {
dir := c.ssh("")
info, err := os.Stat(dir)
if err != nil {
return nil, fmt.Errorf("there is no %s: %v", dir, err)
}
f := []Finding{}
add := func(sev, path, what string, args ...any) {
f = append(f, Finding{Severity: sev, Path: path, What: fmt.Sprintf(what, args...)})
}
if info.Mode().Perm() != 0o700 {
add("problem", dir, "mode %04o, not 0700", info.Mode().Perm())
}
if st, err := os.Stat(c.ssh("config.d")); err != nil {
add("problem", c.ssh("config.d"), "absent: the mesh's own hosts and other modules' drop-ins live there")
} else if st.Mode().Perm() != 0o700 {
add("problem", c.ssh("config.d"), "mode %04o, not 0700", st.Mode().Perm())
}
// Modes, file by file.
entries, _ := os.ReadDir(dir)
keys, _ := c.privateKeys()
isKey := map[string]bool{}
for _, k := range keys {
isKey[k] = true
}
debris := []string{}
for _, e := range entries {
p := c.ssh(e.Name())
st, err := os.Lstat(p)
if err != nil {
continue
}
mode := st.Mode().Perm()
switch {
case st.Mode()&fs.ModeSymlink != 0:
add("note", p, "a symbolic link: ssh follows it, and what it points at is not under ~/.ssh's modes")
case st.IsDir():
if mode&0o022 != 0 {
add("problem", p, "a directory writable by others (%04o)", mode)
}
case isKey[p] && mode&0o077 != 0:
add("problem", p, "a private key readable by others (%04o): ssh refuses to use it", mode)
case e.Name() == "authorized_keys" && mode&0o077 != 0:
add("note", p, "mode %04o: 0600 is enough, and sshd refuses it once group- or world-writable", mode)
case mode&0o022 != 0:
add("problem", p, "writable by others (%04o): ssh refuses a configuration others can write", mode)
}
if regexp.MustCompile(`\.(bak|old|orig)\b|\.bak-|^removed-|\.revoked-`).MatchString(e.Name()) {
debris = append(debris, e.Name())
}
}
// Keys.
keyList, err := c.Keys(ctx)
if err != nil {
return nil, err
}
byFingerprint := map[string][]string{}
for _, k := range keyList {
if k.Fingerprint != "" {
byFingerprint[k.Fingerprint] = append(byFingerprint[k.Fingerprint], k.Path)
}
}
for fp, paths := range byFingerprint {
if len(paths) > 1 {
sort.Strings(paths)
add("note", "", "one key under %d names (%s): %s — revoking one revokes all", len(paths), fp, strings.Join(paths, ", "))
}
}
for _, k := range keyList {
if k.Passphrase == "none" {
add("problem", k.Path, "a private key with no passphrase: whoever reads the file can use it")
}
if k.Weak != "" {
add("problem", k.Path, "%s", k.Weak)
}
if k.AgeDays > 3*365 {
add("note", k.Path, "%d days old", k.AgeDays)
}
if k.OfferedBy == "" {
add("note", k.Path, "no IdentityFile names it and its name is not a default: ssh offers it only from an agent")
}
if k.PublicMissing {
add("note", k.Path, "its public half (.pub) is missing")
}
if k.PublicMismatch != "" {
add("problem", k.Path+".pub", "is not this key's public half: %s", k.PublicMismatch)
}
}
// The configuration.
p, perr := Parse(c.Home)
if perr != nil {
add("problem", c.ssh("config"), "%v", perr)
} else {
if !c.meshIncludeFirst() {
add("problem", c.ssh("config"), "the mesh's region is not the first thing in the file, or brings in no config.d: hosts above it win over the mesh's")
}
if _, err := os.Stat(c.ssh(MeshFile)); err != nil {
add("problem", c.ssh(MeshFile), "absent: the mesh's own hosts are not here")
}
for _, d := range p.Duplicates() {
add("problem", "", "Host %v is defined %d times; the first wins: %v", d["host"], len(d["defined_at"].([]string)), d["defined_at"])
}
for _, prob := range p.Problems {
add("problem", "", "%s", prob)
}
}
// authorized_keys.
auth, _, aerr := c.readAuthorized()
if aerr != nil {
add("problem", c.ssh("authorized_keys"), "%v", aerr)
}
for _, k := range auth {
if k.Weak != "" {
add("problem", c.ssh("authorized_keys"), "line %d (%s): %s", k.Line, k.Fingerprint, k.Weak)
}
if k.Comment == "" {
add("note", c.ssh("authorized_keys"), "line %d (%s) has no comment: nothing says whose it is", k.Line, k.Fingerprint)
}
}
// known_hosts for the mesh's machines.
stale := []map[string]any{}
notChecked := []string{"what any private key is used for elsewhere", "authorized_keys against the mesh's record: the mesh has no record of the operator's keys yet"}
if perr == nil {
hosts := p.MeshHosts()
if !scan {
notChecked = append(notChecked, "known_hosts against what the mesh's machines offer now (scan was false)")
}
var mu sync.Mutex
var wg sync.WaitGroup
for _, h := range hosts {
wg.Add(1)
go func(h map[string]string) {
defer wg.Done()
known, err := c.knownFor(ctx, h["hostname"], 22)
state := ""
switch {
case err != nil:
state = "unread: " + err.Error()
case !scan && len(known) == 0:
state = "not known: the first connection would ask"
case !scan:
return
default:
live, _, serr := c.scan(ctx, h["hostname"], 22)
if serr != nil {
state = "unreachable: " + serr.Error()
} else if s := compare(known, live); s != "matches" {
state = s
} else {
return
}
}
mu.Lock()
stale = append(stale, map[string]any{"host": h["host"], "hostname": h["hostname"], "state": state})
mu.Unlock()
}(h)
}
wg.Wait()
sort.Slice(stale, func(a, b int) bool { return fmt.Sprint(stale[a]["host"]) < fmt.Sprint(stale[b]["host"]) })
for _, s := range stale {
add("problem", c.ssh("known_hosts"), "%s (%s): %s", s["host"], s["hostname"], s["state"])
}
}
if len(debris) > 0 {
add("note", dir, "backups and retired copies lie beside the live files: %s", strings.Join(debris, ", "))
}
problems := 0
for _, x := range f {
if x.Severity == "problem" {
problems++
}
}
return map[string]any{"ok": problems == 0, "problems": problems, "findings": f, "keys": keyList, "debris": debris, "not_checked": notChecked}, nil
}
// meshIncludeFirst is whether ~/.ssh/config begins with the mesh's region and it includes config.d.
func (c *Client) meshIncludeFirst() bool {
raw, err := os.ReadFile(c.ssh("config"))
if err != nil {
return false
}
inRegion, sawInclude := false, false
for _, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
switch {
case t == "":
continue
case strings.HasPrefix(t, "# BEGIN mesh ssh-client."):
inRegion = true
case strings.HasPrefix(t, "# END mesh "):
return inRegion && sawInclude
case !inRegion:
return false
case strings.HasPrefix(strings.ToLower(t), "include ") && strings.Contains(t, "config.d/"):
sawInclude = true
}
}
return false
}
@@ -0,0 +1,246 @@
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
}
@@ -0,0 +1,110 @@
package main
// Keys, by their public half only. A fingerprint is computed here from the public key's bytes, as
// ssh-keygen -l does (SHA256 of the key blob, unpadded base64); a private key is never read by this
// process — its first line, the PEM header, says it is one, and ssh-keygen is asked about it.
import (
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"fmt"
"math/big"
"strings"
)
// KeyTypes are the public key algorithms an authorized_keys or known_hosts line can carry.
var KeyTypes = map[string]bool{
"ssh-ed25519": true, "ssh-rsa": true, "ssh-dss": true,
"ecdsa-sha2-nistp256": true, "ecdsa-sha2-nistp384": true, "ecdsa-sha2-nistp521": true,
"sk-ssh-ed25519@openssh.com": true, "sk-ecdsa-sha2-nistp256@openssh.com": true,
}
// PublicKey is one public key as a line carries it.
type PublicKey struct {
Type string `json:"type"`
Bits int `json:"bits"`
Fingerprint string `json:"fingerprint"`
Comment string `json:"comment"`
Options string `json:"options,omitempty"`
Weak string `json:"weak,omitempty"`
}
// ParseKeyLine reads one authorized_keys line (options, type, key, comment) or a public key file's.
func ParseKeyLine(line string) (PublicKey, error) {
fields := fieldsQuoted(strings.TrimSpace(line))
for i, f := range fields {
if !KeyTypes[f] || i+1 >= len(fields) {
continue
}
k := PublicKey{Type: f, Options: strings.Join(fields[:i], " "), Comment: strings.Join(fields[i+2:], " ")}
blob, err := base64.StdEncoding.DecodeString(fields[i+1])
if err != nil {
return k, fmt.Errorf("the key after %s is not base64", f)
}
sum := sha256.Sum256(blob)
k.Fingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:])
k.Bits = bitsOf(f, blob)
switch {
case f == "ssh-dss":
k.Weak = "DSA: refused by current OpenSSH"
case f == "ssh-rsa" && k.Bits > 0 && k.Bits < 3072:
k.Weak = fmt.Sprintf("RSA of %d bits: below 3072", k.Bits)
}
return k, nil
}
return PublicKey{}, fmt.Errorf("no public key on this line")
}
// fieldsQuoted splits on spaces outside double quotes, as sshd reads an options field.
func fieldsQuoted(s string) []string {
var out []string
var cur strings.Builder
quoted := false
for _, ch := range s {
switch {
case ch == '"':
quoted = !quoted
cur.WriteRune(ch)
case (ch == ' ' || ch == '\t') && !quoted:
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteRune(ch)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// bitsOf reads a key's size from its blob: the RSA modulus, the curve, or ed25519's fixed 256.
func bitsOf(kind string, blob []byte) int {
strs := [][]byte{}
for len(blob) >= 4 {
n := binary.BigEndian.Uint32(blob)
if int(n) > len(blob)-4 {
break
}
strs = append(strs, blob[4:4+n])
blob = blob[4+n:]
}
switch {
case strings.Contains(kind, "ed25519"):
return 256
case kind == "ssh-rsa" && len(strs) >= 3:
return new(big.Int).SetBytes(strs[2]).BitLen()
case kind == "ssh-dss" && len(strs) >= 2:
return new(big.Int).SetBytes(strs[1]).BitLen()
case strings.Contains(kind, "nistp256"):
return 256
case strings.Contains(kind, "nistp384"):
return 384
case strings.Contains(kind, "nistp521"):
return 521
}
return 0
}
@@ -0,0 +1,138 @@
// ssh-client's Go tools bundle (novox/hq ADR 0188, ADR 0193; research 027/03): a process the node's
// tool runtime launches and speaks MCP over stdio to, through the Go SDK. It answers for the
// operator account's ~/.ssh — its hosts and where each came from, its keys, who may log in, the
// hosts it knows — and changes two things on request: one authorized key revoked, one known host
// refreshed. It runs as the operator account (ADR 0175 §4) and never reads a private key.
package main
import (
"context"
"fmt"
"math"
"os"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): ssh-client.
if err := stdio.Serve("", tools(NewClient())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var hostArg = map[string]any{"type": "string", "description": "the host, as you would give it to ssh"}
func tools(c *Client) []stdio.Tool {
ctx := context.Background()
return []stdio.Tool{
{
Name: "ssh_client_hosts",
Description: "Every Host and Match section ssh reads for this account, in the order it reads them (an earlier one wins), each with its file and line " +
"and where it came from: mesh (the mesh's own file or region), drop-in (another file in ~/.ssh/config.d) or operator; with duplicates and what is set outside any section.",
Run: func(map[string]any) (any, error) { return c.Hosts() },
},
{
Name: "ssh_client_resolve",
Description: "What ssh would use for one host (ssh -G): host name, user, port, identity files, proxy, host-key checking — and which sections matched it.",
Input: map[string]any{"host": hostArg},
Run: func(args map[string]any) (any, error) {
h, err := text(args, "host")
if err != nil {
return nil, err
}
return c.Resolve(ctx, h)
},
},
{
Name: "ssh_client_check",
Description: "Everything about ~/.ssh worth a look: modes of the directory and every file, private keys with no passphrase or weak or old, the mesh's region first with its include, " +
"duplicate hosts, known_hosts entries for the mesh's machines that are missing or stale (scanned live unless scan is false), authorized keys without a comment, and debris. Says what it did not check.",
Input: map[string]any{"scan": map[string]any{"type": "boolean", "description": "ask each of the mesh's machines for its host key now (default true; 5 s each, in parallel)"}},
Run: func(args map[string]any) (any, error) { return c.Check(ctx, flag(args, "scan", true)) },
},
{
Name: "ssh_client_keys",
Description: "Every private key under ~/.ssh by its public half: type, size, fingerprint, comment, mode, age, whether it has a passphrase, and whether ssh offers it by itself. The key itself is never read.",
Run: func(map[string]any) (any, error) {
k, err := c.Keys(ctx)
if err != nil {
return nil, err
}
return map[string]any{"count": len(k), "keys": k}, nil
},
},
{
Name: "ssh_client_authorized",
Description: "Who may log in as this account by key: each line of authorized_keys by fingerprint, type, size, comment and options — never the key itself — with duplicates and unreadable lines.",
Run: func(map[string]any) (any, error) { return c.Authorized() },
},
{
Name: "ssh_client_revoke",
Description: "Take one key out of authorized_keys by its fingerprint (every line carrying it), keeping the file as it was beside it first. " +
"Refuses the last key (that would lock ssh out) and a key in the mesh's region (the next push would write it back).",
Input: map[string]any{"fingerprint": map[string]any{"type": "string", "description": "SHA256:… as ssh_client_authorized lists it"}},
Run: func(args map[string]any) (any, error) {
fp, err := text(args, "fingerprint")
if err != nil {
return nil, err
}
return c.Revoke(fp)
},
},
{
Name: "ssh_client_known_host",
Description: "What known_hosts holds for one host and what the host offers now, by fingerprint: matches, changed, not known or unreachable. With refresh true, the host's entries are replaced " +
"by what it offers now (ssh-keygen keeps known_hosts.old) — which trusts whatever answers, so only for a host whose key is known to have changed.",
Input: map[string]any{
"host": hostArg,
"port": map[string]any{"type": "integer", "description": "the ssh port (default 22)"},
"refresh": map[string]any{"type": "boolean", "description": "replace its entries with what it offers now (default false)"},
},
Run: func(args map[string]any) (any, error) {
h, err := text(args, "host")
if err != nil {
return nil, err
}
port := 22
if v, ok := args["port"].(float64); ok {
if v != math.Trunc(v) {
return nil, fmt.Errorf("port must be a whole number")
}
port = int(v)
}
return c.KnownHost(ctx, h, port, flag(args, "refresh", false))
},
},
{
Name: "ssh_client_test",
Description: "Can this machine reach a host and log in: one batch-mode connection (no prompt, runs only `true`), answering the address reached, the method and key that authenticated, " +
"or why it failed and which keys were offered. A key with a passphrase works only through an agent; the answer names the agent it used, or that there was none.",
Input: map[string]any{"host": hostArg},
Run: func(args map[string]any) (any, error) {
h, err := text(args, "host")
if err != nil {
return nil, err
}
return c.Test(ctx, h)
},
},
}
}
func text(args map[string]any, key string) (string, error) {
s, _ := args[key].(string)
if s = strings.TrimSpace(s); s == "" {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
func flag(args map[string]any, key string, def bool) bool {
if b, ok := args[key].(bool); ok {
return b
}
return def
}
@@ -0,0 +1,61 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// Ran is what a command did: its output, its exit status, and why it never ran to an answer.
type Ran struct {
Stdout string
Stderr string
Status int
// Err is "ENOENT" when the program is not installed, or says it was ended for taking too long.
Err string
}
// Runner runs one command with extra environment words, so every tool can be tested without ssh.
type Runner func(ctx context.Context, env []string, name string, args ...string) Ran
// CallTimeout bounds one command: below the runtime's thirty-second call limit.
const CallTimeout = 20 * time.Second
// ExecRunner runs a command on this machine, with no terminal and nothing on its stdin, bounded by
// CallTimeout.
func ExecRunner(ctx context.Context, env []string, name string, args ...string) Ran {
ctx, cancel := context.WithTimeout(ctx, CallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), env...)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
r := Ran{Stdout: out.String(), Stderr: errb.String()}
var exitErr *exec.ExitError
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout/time.Second))
case errors.Is(err, exec.ErrNotFound):
r.Status, r.Err = 127, "ENOENT"
case errors.As(err, &exitErr):
r.Status = exitErr.ExitCode()
case err != nil:
r.Status, r.Err = 1, err.Error()
}
return r
}
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
@@ -0,0 +1,409 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"time"
)
// Public keys made for these tests only; their private halves were never kept.
const (
edPub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOXSwIW0g4H2OOL8D3K21xsmE9p6f9xaj2os361ZKx2A op@laptop"
rsaPub = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDPb48PksTrIhBQxAVc7r1WHzOVQXa5XlwvUNLt0mkcZlSv4K9nHdKRK3LET7Tkuw2PgLhN4ttb058GGILQh24uD2/dfr7R5cCQTS6Z4iRTvTk2EG/HU9RKaNUJWrQc8kKQmx4+H4IgKIarqSpRw/ZTTyyylBV6+xNSMuZGJAHTZNN9Wn6VHlJEE5/IV95Uj+RqEO4+q7RtQC0dV+GWKUXvT5BFEpoU3AfS8yAgGwyotz8RxJktwel6YnafaHD8iNlj1rLo6k9HDXSKAEWk3hBjwO0YIquw6YuJFXGkoY72lbLt+h8/1sfTjjvZosRlWkejkAsU5W/Nb0Y37nt1nXwR old@ci"
edFP = "SHA256:qgWtIXM3wAqg5va+Mzzx7SJGwA7etBQT6Z7Bs3fLVCY"
rsaFP = "SHA256:6Fb092rWEQVP4y+ewi3r2hORvWlm6iFkfHT6BNB9T/Q"
)
type call struct {
env []string
name string
args []string
}
type fake struct {
answer func(c call) Ran
calls []call
}
func (f *fake) run(_ context.Context, env []string, name string, args ...string) Ran {
c := call{env, name, args}
f.calls = append(f.calls, c)
if f.answer == nil {
return Ran{Status: 1}
}
return f.answer(c)
}
func home(t *testing.T, files map[string]string) string {
t.Helper()
h := t.TempDir()
if err := os.MkdirAll(filepath.Join(h, ".ssh", "config.d"), 0o700); err != nil {
t.Fatal(err)
}
os.Chmod(filepath.Join(h, ".ssh"), 0o700)
for name, content := range files {
mode := os.FileMode(0o600)
if strings.HasSuffix(name, ".pub") {
mode = 0o644
}
p := filepath.Join(h, ".ssh", name)
os.MkdirAll(filepath.Dir(p), 0o700)
if err := os.WriteFile(p, []byte(strings.ReplaceAll(content, "HOME", h)), mode); err != nil {
t.Fatal(err)
}
}
return h
}
func client(h string, f *fake) *Client {
return &Client{Home: h, UID: 4242, Run: f.run, Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }}
}
// The layout this module writes: its region first, bringing in config.d; its own hosts in 00-mesh;
// a found predecessor file and a module's drop-in beside it; the operator's lines below.
var layout = map[string]string{
"config": "# BEGIN mesh ssh-client.config\n# the mesh's region\nInclude ~/.ssh/config.d/*\n# END mesh ssh-client.config\n\n" +
"ServerAliveInterval 30\nHost *\n AddKeysToAgent yes\nHost ace\n User wrong\nHost box\n HostName 192.0.2.7\n IdentityFile ~/.ssh/id_box\n",
"config.d/00-mesh": "# Generated by the mesh. Do not edit\n\nHost ace ace.internal\n HostName ace.internal\n User ace\n\nHost shanks shanks.internal\n HostName shanks.internal\n User op\n",
"config.d/mesh": "Host ace\n\tHostName ace.internal\n\tUser ace\n",
"config.d/work": "# Generated by the mesh. Do not edit\nHost forge.example\n Port 2222\n",
}
func TestHostsSayWhereEachCameFromInTheOrderSshReadsThem(t *testing.T) {
c := client(home(t, layout), &fake{})
got, err := c.Hosts()
if err != nil {
t.Fatal(err)
}
var who []string
for _, s := range got["sections"].([]Section) {
who = append(who, s.Patterns[0]+"@"+s.Source+"/"+s.From)
}
want := []string{"ace@config.d/00-mesh/mesh", "shanks@config.d/00-mesh/mesh", "ace@config.d/mesh/drop-in",
"forge.example@config.d/work/drop-in", "*@config/operator", "ace@config/operator", "box@config/operator"}
if !reflect.DeepEqual(who, want) {
t.Fatalf("order/source:\n%v\nwant\n%v", who, want)
}
if !reflect.DeepEqual(got["global"], []string{"config:6 ServerAliveInterval 30"}) {
t.Errorf("an operator line after the include read as part of the last included section: %v", got["global"])
}
dups := got["duplicates"].([]map[string]any)
if len(dups) != 1 || dups[0]["host"] != "ace" || dups[0]["wins"] != "config.d/00-mesh:3 (mesh)" {
t.Errorf("duplicates: %v", dups)
}
sections := got["sections"].([]Section)
if !sections[3].Mesh || sections[2].Mesh {
t.Errorf("a drop-in under the mesh's header is the mesh's; one without is found: %+v %+v", sections[3], sections[2])
}
}
func TestTheMeshsRegionMustComeFirstAndBringInConfigD(t *testing.T) {
h := home(t, layout)
if !client(h, &fake{}).meshIncludeFirst() {
t.Fatal("the written layout was not recognised")
}
os.WriteFile(filepath.Join(h, ".ssh", "config"), []byte("Host early\n User x\n"+layout["config"]), 0o600)
if client(h, &fake{}).meshIncludeFirst() {
t.Fatal("a host above the mesh's region passed")
}
}
func TestKeysAreDescribedByTheirPublicHalfAndAPassphraseIsAskedNotRead(t *testing.T) {
files := map[string]string{"config": layout["config"], "id_ed25519": "-----BEGIN OPENSSH PRIVATE KEY-----\nnot a key\n",
"id_ed25519.pub": edPub + "\n", "id_box": "-----BEGIN OPENSSH PRIVATE KEY-----\nnot a key\n", "id_box.pub": rsaPub + "\n",
"stray": "-----BEGIN RSA PRIVATE KEY-----\nnot a key\n", "notes.txt": "hello\n"}
f := &fake{answer: func(c call) Ran {
if c.name == "ssh-keygen" && c.args[0] == "-y" {
if strings.HasSuffix(c.args[len(c.args)-1], "id_box") {
return Ran{Status: 1, Stderr: "Load key \"id_box\": incorrect passphrase supplied to decrypt private key\n"}
}
if strings.HasSuffix(c.args[len(c.args)-1], "id_ed25519") {
return Ran{Stdout: edPub + "\n"}
}
}
if c.name == "ssh-keygen" && c.args[0] == "-l" {
return Ran{Stdout: "256 " + edFP + " no comment (ED25519)\n"}
}
return Ran{Status: 1, Stderr: "unexpected"}
}}
keys, err := client(home(t, files), f).Keys(context.Background())
if err != nil {
t.Fatal(err)
}
by := map[string]Key{}
for _, k := range keys {
by[filepath.Base(k.Path)] = k
}
if len(keys) != 3 {
t.Fatalf("%+v", keys)
}
if k := by["id_ed25519"]; k.Fingerprint != edFP || k.Passphrase != "none" || !strings.HasPrefix(k.OfferedBy, "default name") || k.Comment != "op@laptop" {
t.Errorf("ed25519: %+v", k)
}
if k := by["id_box"]; k.Passphrase != "yes" || k.Bits != 2048 || k.Weak == "" || !strings.HasPrefix(k.OfferedBy, "IdentityFile at config:") {
t.Errorf("box: %+v", k)
}
if k := by["stray"]; !k.PublicMissing || k.OfferedBy != "" || k.Fingerprint != edFP {
t.Errorf("stray: %+v", k)
}
for _, c := range f.calls {
if c.name == "ssh-keygen" && c.args[0] == "-y" && !reflect.DeepEqual(c.args[:3], []string{"-y", "-P", ""}) {
t.Errorf("a passphrase check could prompt: %v", c.args)
}
}
}
func TestAPublicHalfThatIsAnotherKeysIsFound(t *testing.T) {
files := map[string]string{"config": layout["config"], "id_ed25519": "-----BEGIN OPENSSH PRIVATE KEY-----\n", "id_ed25519.pub": rsaPub + "\n"}
f := &fake{answer: func(c call) Ran { return Ran{Stdout: edPub + "\n"} }}
keys, _ := client(home(t, files), f).Keys(context.Background())
if len(keys) != 1 || keys[0].PublicMismatch == "" || keys[0].Fingerprint != edFP {
t.Fatalf("%+v", keys)
}
}
func TestFingerprintsAreSshKeygens(t *testing.T) {
k, err := ParseKeyLine("from=\"10.0.0.0/8,192.0.2.1\",no-pty " + edPub)
if err != nil || k.Fingerprint != edFP || k.Bits != 256 || k.Comment != "op@laptop" || !strings.HasPrefix(k.Options, "from=") {
t.Fatalf("%+v %v", k, err)
}
k, _ = ParseKeyLine(rsaPub)
if k.Fingerprint != rsaFP || k.Bits != 2048 || !strings.Contains(k.Weak, "below 3072") {
t.Fatalf("%+v", k)
}
if _, err := ParseKeyLine("ssh-ed25519 !!!notbase64"); err == nil {
t.Fatal("garbage accepted")
}
}
func TestAuthorizedListsFingerprintsNeverKeys(t *testing.T) {
h := home(t, map[string]string{"config": layout["config"], "authorized_keys": "# mine\n" + edPub + "\n" + rsaPub + "\nnonsense line\n" + edPub + "\n"})
got, err := client(h, &fake{}).Authorized()
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
if strings.Contains(string(b), "AAAA") {
t.Fatalf("a key was printed: %s", b)
}
if got["count"] != 3 || !reflect.DeepEqual(got["duplicates"], []string{edFP}) || !reflect.DeepEqual(got["unreadable_lines"], []int{4}) {
t.Fatalf("%s", b)
}
}
func TestRevokeKeepsTheFileFirstAndRefusesALockout(t *testing.T) {
original := "# mine\n" + edPub + "\n" + rsaPub + "\n"
h := home(t, map[string]string{"config": layout["config"], "authorized_keys": original})
c := client(h, &fake{})
got, err := c.Revoke(rsaFP)
if err != nil {
t.Fatal(err)
}
now, _ := os.ReadFile(filepath.Join(h, ".ssh", "authorized_keys"))
if string(now) != "# mine\n"+edPub+"\n" {
t.Fatalf("after: %q", now)
}
kept, _ := os.ReadFile(got["backup"].(string))
if string(kept) != original {
t.Fatal("the backup is not the file as it was")
}
if info, _ := os.Stat(filepath.Join(h, ".ssh", "authorized_keys")); info.Mode().Perm() != 0o600 {
t.Errorf("mode changed: %v", info.Mode())
}
if _, err := c.Revoke(edFP); err == nil || !strings.Contains(err.Error(), "lock ssh out") {
t.Fatalf("the last key was revoked: %v", err)
}
if _, err := c.Revoke("SHA256:nothing"); err == nil {
t.Fatal("an unknown fingerprint was accepted")
}
h = home(t, map[string]string{"authorized_keys": "# BEGIN mesh ssh-client.authorized\n" + rsaPub + "\n# END mesh ssh-client.authorized\n" + edPub + "\n"})
if _, err := client(h, &fake{}).Revoke(rsaFP); err == nil || !strings.Contains(err.Error(), "mesh's region") {
t.Fatalf("a key of the mesh's region was revoked: %v", err)
}
}
func scanOf(host, pub string) string { return host + " " + pub + "\n" }
func TestKnownHostComparesWhatIsKnownWithWhatIsOffered(t *testing.T) {
h := home(t, map[string]string{"config": layout["config"], "known_hosts": "ace.internal " + edPub + "\n"})
offered := edPub
f := &fake{answer: func(c call) Ran {
switch {
case c.name == "ssh-keygen" && c.args[0] == "-F":
return Ran{Stdout: "# Host ace.internal found: line 1\nace.internal " + edPub + "\n"}
case c.name == "ssh-keyscan":
return Ran{Stdout: scanOf("ace.internal", offered)}
case c.name == "ssh-keygen" && c.args[0] == "-R":
return Ran{}
}
return Ran{Status: 1}
}}
c := client(h, f)
got, err := c.KnownHost(context.Background(), "ace.internal", 22, false)
if err != nil || got["state"] != "matches" {
t.Fatalf("%v %v", got, err)
}
offered = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZha2VrZXlmYWtla2V5ZmFrZWtleWZha2VrZXlmYWs="
got, _ = c.KnownHost(context.Background(), "ace.internal", 22, false)
if !strings.HasPrefix(got["state"].(string), "changed") {
t.Fatalf("%v", got["state"])
}
got, err = c.KnownHost(context.Background(), "ace.internal", 22, true)
if err != nil || got["refreshed"] != true {
t.Fatalf("%v %v", got, err)
}
after, _ := os.ReadFile(filepath.Join(h, ".ssh", "known_hosts"))
if !strings.Contains(string(after), offered) {
t.Fatalf("not appended: %s", after)
}
sawRemove := false
for _, c := range f.calls {
if c.name == "ssh-keygen" && reflect.DeepEqual(c.args[:2], []string{"-R", "ace.internal"}) {
sawRemove = true
}
if c.name == "ssh-keyscan" && !reflect.DeepEqual(c.args[:4], []string{"-T", "5", "-p", "22"}) {
t.Errorf("an unbounded scan: %v", c.args)
}
}
if !sawRemove {
t.Fatal("the old entry was not removed through ssh-keygen (which keeps known_hosts.old)")
}
if _, err := c.KnownHost(context.Background(), "-oProxyCommand=x", 22, false); err == nil {
t.Fatal("an option was taken for a host")
}
}
func TestAnUnreachableHostIsNotRefreshed(t *testing.T) {
h := home(t, map[string]string{"known_hosts": ""})
f := &fake{answer: func(c call) Ran {
if c.name == "ssh-keyscan" {
return Ran{Status: 1}
}
return Ran{Status: 1}
}}
got, err := client(h, f).KnownHost(context.Background(), "gone.example", 22, false)
if err != nil || !strings.HasPrefix(got["state"].(string), "unreachable") {
t.Fatalf("%v %v", got, err)
}
if _, err := client(h, f).KnownHost(context.Background(), "gone.example", 22, true); err == nil {
t.Fatal("refreshed from nothing")
}
}
func TestTestSaysHowItAuthenticatedOrWhyNot(t *testing.T) {
ok := "debug1: Connecting to ace.internal [10.0.0.2] port 22.\ndebug1: Server accepts key: /h/.ssh/id_ed25519 ED25519 " + edFP + "\nAuthenticated to ace.internal ([10.0.0.2]:22) using \"publickey\".\n"
f := &fake{answer: func(c call) Ran { return Ran{Stderr: ok} }}
got, err := client(t.TempDir(), f).Test(context.Background(), "ace")
if err != nil || got["ok"] != true || got["method"] != "publickey" || got["connected_to"] != "10.0.0.2:22" {
t.Fatalf("%v %v", got, err)
}
args := strings.Join(f.calls[0].args, " ")
if !strings.Contains(args, "BatchMode=yes") || !strings.Contains(args, "StrictHostKeyChecking=yes") || !strings.HasSuffix(args, "ace true") {
t.Fatalf("not a batch test: %s", args)
}
denied := "debug1: Offering public key: /h/.ssh/id_box RSA " + rsaFP + "\nop@ace.internal: Permission denied (publickey).\n"
f = &fake{answer: func(c call) Ran { return Ran{Status: 255, Stderr: denied} }}
got, _ = client(t.TempDir(), f).Test(context.Background(), "ace")
if got["ok"] != false || got["why"] != "no key it offered was accepted" || !reflect.DeepEqual(got["offered"], []string{"/h/.ssh/id_box"}) {
t.Fatalf("%v", got)
}
if !strings.Contains(got["note"].(string), "agent") {
t.Fatalf("no word on the agent: %v", got)
}
}
func TestCheckFindsWhatIsWrongAndSaysWhatItDidNotCheck(t *testing.T) {
files := map[string]string{"config": "Host early\n User x\n" + layout["config"], "config.d/00-mesh": layout["config.d/00-mesh"],
"config.d/mesh": layout["config.d/mesh"], "id_ed25519": "-----BEGIN OPENSSH PRIVATE KEY-----\n", "id_ed25519.pub": edPub + "\n",
"authorized_keys": rsaPub + "\n", "known_hosts": "", "config.bak-1": "x"}
h := home(t, files)
os.Chmod(filepath.Join(h, ".ssh", "config"), 0o666)
os.Chmod(filepath.Join(h, ".ssh", "id_ed25519"), 0o644)
f := &fake{answer: func(c call) Ran {
switch {
case c.name == "ssh-keygen" && c.args[0] == "-y":
return Ran{Stdout: edPub}
case c.name == "ssh-keyscan":
return Ran{Stdout: scanOf("x", edPub)}
}
return Ran{Status: 1}
}}
got, err := client(h, f).Check(context.Background(), true)
if err != nil {
t.Fatal(err)
}
var whats []string
for _, x := range got["findings"].([]Finding) {
whats = append(whats, x.What)
}
all := strings.Join(whats, "\n")
for _, want := range []string{"writable by others (0666)", "private key readable by others (0644)", "no passphrase",
"not the first thing in the file", "Host ace is defined 3 times", "RSA of 2048 bits", "not known: the first connection would ask", "config.bak-1"} {
if !strings.Contains(all, want) {
t.Errorf("missing %q in:\n%s", want, all)
}
}
if got["ok"] != false || len(got["not_checked"].([]string)) == 0 {
t.Errorf("%v", got)
}
}
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Tools []string `json:"tools"`
}
json.Unmarshal(raw, &m)
served := []string{}
for _, tool := range tools(client(t.TempDir(), &fake{})) {
if !strings.HasPrefix(tool.Name, "ssh_client_") || tool.Description == "" {
t.Errorf("tool %q", tool.Name)
}
served = append(served, tool.Name)
}
sort.Strings(served)
sort.Strings(m.Tools)
if !reflect.DeepEqual(served, m.Tools) {
t.Fatalf("served %v, manifest %v", served, m.Tools)
}
}
// The module's own region, as the manifest declares it, read by ssh: the mesh's hosts first, a
// drop-in next, the operator's lines after, and an operator line after the region global again.
func TestTheManifestsRegionIsWhatSshReads(t *testing.T) {
raw, _ := os.ReadFile("../../module.json")
var m struct {
Resources []map[string]any `json:"resources"`
}
json.Unmarshal(raw, &m)
var region string
for _, r := range m.Resources {
if r["id"] == "config" {
region = r["content"].(string)
if r["into"] != "block" || r["at"] != "start" {
t.Fatalf("the region is not written into the start: %v", r)
}
}
}
if !strings.Contains(region, "Include ~/.ssh/config.d/*") {
t.Fatalf("no include: %q", region)
}
h := home(t, map[string]string{"config": "# BEGIN mesh ssh-client.config\n" + region + "# END mesh ssh-client.config\n\nCompression yes\nHost ace\n User wrong\n",
"config.d/00-mesh": layout["config.d/00-mesh"]})
p, err := Parse(h)
if err != nil {
t.Fatal(err)
}
if p.Sections[0].Source != MeshFile || p.Sections[0].Options["user"] != "ace" || len(p.Global) != 1 || !strings.HasSuffix(p.Global[0], " Compression yes") {
t.Fatalf("%+v %v", p.Sections, p.Global)
}
}
+5
View File
@@ -0,0 +1,5 @@
module sshclient
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+45 -4
View File
@@ -1,6 +1,16 @@
{
"module": "ssh-client",
"version": "1",
"tools": [
"ssh_client_hosts",
"ssh_client_resolve",
"ssh_client_check",
"ssh_client_keys",
"ssh_client_authorized",
"ssh_client_revoke",
"ssh_client_known_host",
"ssh_client_test"
],
"resources": [
{
"id": "ssh-dir",
@@ -8,14 +18,45 @@
"path": "${machine:account-home}/.ssh",
"owner": "${machine:account}",
"mode": "0700"
},
{
"id": "config-d",
"type": "directory",
"path": "${machine:account-home}/.ssh/config.d",
"owner": "${machine:account}",
"mode": "0700"
},
{
"id": "config",
"type": "file",
"path": "${machine:account-home}/.ssh/config",
"owner": "${machine:account}",
"mode": "0600",
"into": "block",
"at": "start",
"content": "# The mesh's region (module ssh-client, novox/hq research 027/03). It comes first because ssh\n# takes the first value it finds for each option. It brings in ~/.ssh/config.d/ in name order:\n# 00-mesh, the mesh's Host per machine, then each file another module places there. Replaced at\n# every push. Everything below it is yours, kept as you wrote it, and applies to every host the\n# files above leave unsettled.\nInclude ~/.ssh/config.d/*\n"
}
],
"facts": {
"ssh-config": {
"path": ".ssh/config",
"mesh-hosts": {
"path": ".ssh/config.d/00-mesh",
"home": true,
"shared": true,
"template": "# The mesh's Host blocks — every other node, so `ssh <node>` reaches it as the\n# right account. This region is replaced whenever a node joins, leaves or is\n# renamed; the rest of this file is yours and is kept untouched.\n{{range .Machines}}{{if ne .Name $.Node}}\nHost {{.Name}} {{.FQDN}}\n HostName {{.FQDN}}\n{{if .Account}} User {{.Account}}\n{{end}}{{end}}{{end}}"
"template": "# Generated by the mesh. Do not edit — module ssh-client writes this file whenever a machine\n# joins, leaves or is renamed. ~/.ssh/config includes it first, so these hosts win over every\n# later line; a host of your own goes below the mesh's region in ~/.ssh/config.\n{{range .Machines}}{{if ne .Name $.Node}}\nHost {{.Name}} {{.FQDN}}\n HostName {{.FQDN}}\n{{if .Account}} User {{.Account}}\n{{end}}{{end}}{{end}}"
}
},
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/ssh-client-tools",
"binary": "ssh-client-tools",
"loads": [
"ssh-client-tools"
]
}
]
}
}