Files
mesh-catalog/modules/ssh-client/cmd/ssh-client-tools/main.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

139 lines
5.8 KiB
Go

// 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
}