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.
139 lines
5.8 KiB
Go
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
|
|
}
|