Keep the machine's forge SSH key and its keys entry (issue 955 S1)
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
// The key this machine's account uses to reach the forge over SSH (novox/hq ADR 0399, ADR 0400; issue 955,
|
||||
// area S1). It is made here, on the machine, once: ssh-keygen writes ~/.ssh/mesh_ed25519 only when it is
|
||||
// missing, so the private half never leaves the machine and an existing key is never replaced. The public
|
||||
// half and its fingerprint go into this module's `keys` state, one entry per account under <node>.<account>,
|
||||
// for the forge's provider to register. The tools bundle is the one process of this module, so the keeper
|
||||
// runs inside it beside the tools (main.go), the way claude-code reports what it holds.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KeyFile is the key's name under ~/.ssh: never one of the operator's own id_* files.
|
||||
const KeyFile = "mesh_ed25519"
|
||||
|
||||
// KeyKind is the kind of key this process makes: the machine's account's own (ADR 0400 §1).
|
||||
const KeyKind = "machine"
|
||||
|
||||
// KeyRetry is how soon it looks again after a failure.
|
||||
const KeyRetry = 30 * time.Second
|
||||
|
||||
// KeyRound is how often the keeper looks again.
|
||||
const KeyRound = 10 * time.Minute
|
||||
|
||||
// KeyEntry is one value of the keys state. It carries the public half only.
|
||||
type KeyEntry struct {
|
||||
Node string `json:"node"`
|
||||
Account string `json:"account"`
|
||||
Kind string `json:"kind"`
|
||||
Public string `json:"public"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
// KeyPutter is the keys state, as far as the keeper needs it.
|
||||
type KeyPutter interface {
|
||||
Put(key string, value any) error
|
||||
}
|
||||
|
||||
// KeyKeeper keeps one account's key and its entry.
|
||||
type KeyKeeper struct {
|
||||
Home, Node, Account string
|
||||
Run Runner
|
||||
State KeyPutter
|
||||
Now func() time.Time
|
||||
|
||||
reported string // the public half and fingerprint last put
|
||||
}
|
||||
|
||||
func (k *KeyKeeper) path() string { return filepath.Join(k.Home, ".ssh", KeyFile) }
|
||||
|
||||
// Once makes the key when it is missing and puts the entry when it differs from the one last put.
|
||||
func (k *KeyKeeper) Once(ctx context.Context) error {
|
||||
if err := k.ensure(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(k.path() + ".pub")
|
||||
if err != nil {
|
||||
return fmt.Errorf("the public half: %w", err)
|
||||
}
|
||||
pk, err := ParseKeyLine(string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s.pub: %w", k.path(), err)
|
||||
}
|
||||
public := strings.TrimSpace(string(raw))
|
||||
if sig := public + "|" + pk.Fingerprint; sig == k.reported {
|
||||
return nil
|
||||
}
|
||||
entry := KeyEntry{Node: k.Node, Account: k.Account, Kind: KeyKind, Public: public,
|
||||
Fingerprint: pk.Fingerprint, At: k.Now().UTC().Format(time.RFC3339)}
|
||||
if err := k.State.Put(k.Node+"."+k.Account, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
k.reported = public + "|" + pk.Fingerprint
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensure makes the pair when the private half is missing. Never over an existing key.
|
||||
func (k *KeyKeeper) ensure(ctx context.Context) error {
|
||||
if _, err := os.Lstat(k.path()); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(k.path()), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
r := k.Run(ctx, nil, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", k.Account+"@"+k.Node+" (mesh)", "-f", k.path())
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return fmt.Errorf("ssh-keygen: %s", firstLine(r.Err+" "+r.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keep runs Once now and every KeyRound, reporting a failure through say and trying again at the next round.
|
||||
func (k *KeyKeeper) Keep(ctx context.Context, say func(string, ...any)) {
|
||||
for {
|
||||
pause := KeyRound
|
||||
if err := k.Once(ctx); err != nil {
|
||||
say("the forge key: %v", err)
|
||||
pause = KeyRetry // the state or the bus's grant may arrive after the process
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(pause):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue 955, area S1: the key the machine's account makes for the forge, and its entry in the keys state.
|
||||
|
||||
type fakeState struct{ puts map[string][]byte }
|
||||
|
||||
func (f *fakeState) Put(key string, value any) error {
|
||||
raw, _ := json.Marshal(value)
|
||||
if f.puts == nil {
|
||||
f.puts = map[string][]byte{}
|
||||
}
|
||||
f.puts[key] = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
// ed25519Line is a public key line with a well-formed ed25519 blob.
|
||||
func ed25519Line(seed byte, comment string) string {
|
||||
blob := []byte{}
|
||||
for _, part := range [][]byte{[]byte("ssh-ed25519"), make([]byte, 32)} {
|
||||
part2 := append([]byte(nil), part...)
|
||||
if len(part2) == 32 {
|
||||
part2[0] = seed
|
||||
}
|
||||
l := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(l, uint32(len(part2)))
|
||||
blob = append(append(blob, l...), part2...)
|
||||
}
|
||||
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(blob) + " " + comment
|
||||
}
|
||||
|
||||
// keygenRunner stands in for ssh-keygen -t ed25519: it writes the pair the command names.
|
||||
func keygenRunner(calls *[][]string) Runner {
|
||||
return func(ctx context.Context, env []string, name string, args ...string) Ran {
|
||||
*calls = append(*calls, append([]string{name}, args...))
|
||||
var file, comment string
|
||||
for i, a := range args {
|
||||
switch a {
|
||||
case "-f":
|
||||
file = args[i+1]
|
||||
case "-C":
|
||||
comment = args[i+1]
|
||||
}
|
||||
}
|
||||
if name != "ssh-keygen" || file == "" {
|
||||
return Ran{Status: 1, Stderr: "unexpected"}
|
||||
}
|
||||
os.WriteFile(file, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE-SECRET\n"), 0o600)
|
||||
os.WriteFile(file+".pub", []byte(ed25519Line(7, comment)+"\n"), 0o644)
|
||||
return Ran{}
|
||||
}
|
||||
}
|
||||
|
||||
func newKeeper(t *testing.T, home string, st *fakeState, calls *[][]string) *KeyKeeper {
|
||||
return &KeyKeeper{Home: home, Node: "g14", Account: "nox", Run: keygenRunner(calls), State: st,
|
||||
Now: func() time.Time { return time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) }}
|
||||
}
|
||||
|
||||
func TestKeeperMakesTheKeyAndWritesItsEntry(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
st, calls := &fakeState{}, &[][]string{}
|
||||
if err := newKeeper(t, home, st, calls).Once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, ok := st.puts["g14.nox"]
|
||||
if !ok || len(st.puts) != 1 {
|
||||
t.Fatalf("entries = %v, want one under g14.nox", st.puts)
|
||||
}
|
||||
var e map[string]string
|
||||
json.Unmarshal(raw, &e)
|
||||
if e["node"] != "g14" || e["account"] != "nox" || e["kind"] != "machine" || e["at"] != "2026-10-16T12:00:00Z" {
|
||||
t.Errorf("entry = %v", e)
|
||||
}
|
||||
if !strings.HasPrefix(e["public"], "ssh-ed25519 ") || !strings.HasSuffix(e["public"], " nox@g14 (mesh)") {
|
||||
t.Errorf("public = %q", e["public"])
|
||||
}
|
||||
if !strings.HasPrefix(e["fingerprint"], "SHA256:") {
|
||||
t.Errorf("fingerprint = %q", e["fingerprint"])
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
|
||||
t.Errorf("the entry carries private material: %s", raw)
|
||||
}
|
||||
cmd := strings.Join((*calls)[0], " ")
|
||||
for _, want := range []string{"-t ed25519", "-N ", "-C nox@g14 (mesh)", filepath.Join(home, ".ssh", "mesh_ed25519")} {
|
||||
if !strings.Contains(cmd, want) {
|
||||
t.Errorf("ssh-keygen call %q lacks %q", cmd, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeeperKeepsAnExistingKeyByteForByte(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(home, ".ssh"), 0o700)
|
||||
priv, pub := filepath.Join(home, ".ssh", "mesh_ed25519"), filepath.Join(home, ".ssh", "mesh_ed25519.pub")
|
||||
os.WriteFile(priv, []byte("mine"), 0o600)
|
||||
os.WriteFile(pub, []byte(ed25519Line(9, "nox@g14 (mesh)")+"\n"), 0o644)
|
||||
st, calls := &fakeState{}, &[][]string{}
|
||||
k := newKeeper(t, home, st, calls)
|
||||
if err := k.Once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*calls) != 0 {
|
||||
t.Errorf("ssh-keygen ran over an existing key: %v", *calls)
|
||||
}
|
||||
if b, _ := os.ReadFile(priv); string(b) != "mine" {
|
||||
t.Errorf("private file changed: %q", b)
|
||||
}
|
||||
if _, ok := st.puts["g14.nox"]; !ok {
|
||||
t.Errorf("no entry for the existing key")
|
||||
}
|
||||
// A second round with nothing changed writes nothing.
|
||||
delete(st.puts, "g14.nox")
|
||||
k.Once(context.Background())
|
||||
if len(st.puts) != 0 {
|
||||
t.Errorf("an unchanged key was put again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeeperNeverTouchesTheOperatorsOwnKeys(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(home, ".ssh"), 0o700)
|
||||
own := filepath.Join(home, ".ssh", "id_ed25519")
|
||||
os.WriteFile(own, []byte("operator"), 0o600)
|
||||
st, calls := &fakeState{}, &[][]string{}
|
||||
newKeeper(t, home, st, calls).Once(context.Background())
|
||||
if b, _ := os.ReadFile(own); string(b) != "operator" {
|
||||
t.Errorf("the operator's key changed")
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,15 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
go startKeeper()
|
||||
// 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)
|
||||
@@ -136,3 +139,23 @@ func flag(args map[string]any, key string, def bool) bool {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// startKeeper runs the forge key's keeper (keykeeper.go) when the runtime launched this process for a node:
|
||||
// outside a launch — a build, a check — there is no state to put the entry in.
|
||||
func startKeeper() {
|
||||
node := strings.TrimSpace(os.Getenv("MESH_NODE"))
|
||||
c := NewClient()
|
||||
u, err := user.Current()
|
||||
if node == "" || err != nil || c.Home == "" {
|
||||
return
|
||||
}
|
||||
k := &KeyKeeper{Home: c.Home, Node: node, Account: u.Username, Run: ExecRunner, State: keysState{}, Now: time.Now}
|
||||
k.Keep(context.Background(), func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[ssh-client] "+f+"\n", a...) })
|
||||
}
|
||||
|
||||
type keysState struct{}
|
||||
|
||||
func (keysState) Put(key string, value any) error {
|
||||
_, err := stdio.State("keys").Put(key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user