diff --git a/modules/ssh-client/cmd/ssh-client-tools/keykeeper.go b/modules/ssh-client/cmd/ssh-client-tools/keykeeper.go new file mode 100644 index 00000000..be736d96 --- /dev/null +++ b/modules/ssh-client/cmd/ssh-client-tools/keykeeper.go @@ -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 ., +// 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): + } + } +} diff --git a/modules/ssh-client/cmd/ssh-client-tools/keykeeper_bus_test.go b/modules/ssh-client/cmd/ssh-client-tools/keykeeper_bus_test.go new file mode 100644 index 00000000..80df4fb5 --- /dev/null +++ b/modules/ssh-client/cmd/ssh-client-tools/keykeeper_bus_test.go @@ -0,0 +1,190 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// Issue 955, area S4. The acceptance: "a machine's account makes its forge key, and the key's public half is +// in ssh-client's keys state under ." — proved over a real bus. A JetStream server is linked into +// the test and started for it alone; the module is this test binary started again as the bundle, speaking MCP on +// its standard streams to a runtime played here, which keeps the state in buckets of the bus as the tool runner +// does. It runs in the forge's merge check, not on the way: `go test -short` leaves it out. + +const bundleVar = "MESH_SSH_CLIENT_TEST_BUNDLE" + +func TestMain(m *testing.M) { + if os.Getenv(bundleVar) == "1" { + main() + os.Exit(0) + } + os.Exit(m.Run()) +} + +type keysRuntime struct { + t *testing.T + js jetstream.JetStream + in io.WriteCloser + mu sync.Mutex + buckets map[string]jetstream.KeyValue +} + +func (r *keysRuntime) bucket(name string) jetstream.KeyValue { + r.mu.Lock() + defer r.mu.Unlock() + if kv, ok := r.buckets[name]; ok { + return kv + } + kv, err := r.js.CreateOrUpdateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: name}) + if err != nil { + r.t.Fatal(err) + } + r.buckets[name] = kv + return kv +} + +func (r *keysRuntime) say(m map[string]any) { + m["jsonrpc"] = "2.0" + raw, _ := json.Marshal(m) + r.mu.Lock() + defer r.mu.Unlock() + _, _ = r.in.Write(append(raw, '\n')) +} + +func (r *keysRuntime) read(out io.Reader) { + sc := bufio.NewScanner(out) + sc.Buffer(make([]byte, 1<<20), 1<<24) + for sc.Scan() { + var m struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if json.Unmarshal(sc.Bytes(), &m) != nil || m.Method == "" { + continue + } + go r.runtime(m.ID, m.Method, m.Params) + } +} + +// runtime is what the tool runner does for the bundle: only the state this module puts. +func (r *keysRuntime) runtime(id json.RawMessage, method string, params json.RawMessage) { + var p struct { + State string `json:"state"` + Key string `json:"key"` + Value json.RawMessage `json:"value"` + } + _ = json.Unmarshal(params, &p) + if method != "mesh/state.put" { + r.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": fmt.Sprintf("the test runtime offers no %s", method)}}) + return + } + rev, err := r.bucket(p.State).Put(context.Background(), p.Key, p.Value) + if err != nil { + r.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}}) + return + } + r.say(map[string]any{"id": id, "result": map[string]any{"revision": rev}}) +} + +type keysLog struct{ t *testing.T } + +func (l keysLog) Write(p []byte) (int, error) { l.t.Logf("bundle: %s", p); return len(p), nil } + +func TestTheKeyProcessMakesTheKeyAndPutsItsEntryOverTheBus(t *testing.T) { + if testing.Short() { + t.Skip("integration test: runs in the forge's merge check") + } + s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true}) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(30 * time.Second) { + t.Fatal("no bus within 30s") + } + t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() }) + nc, err := nats.Connect(s.ClientURL()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, _ := jetstream.New(nc) + r := &keysRuntime{t: t, js: js, buckets: map[string]jetstream.KeyValue{}} + + account, err := user.Current() + if err != nil { + t.Fatal(err) + } + home := t.TempDir() + path := os.Getenv("PATH") + if _, err := exec.LookPath("ssh-keygen"); err != nil { + // A build host without OpenSSH: a stand-in that writes the pair the command names. + bin := t.TempDir() + stub := "#!/bin/sh\nwhile [ $# -gt 0 ]; do case $1 in -f) f=$2;; -C) c=$2;; esac; shift; done\n" + + "printf 'PRIVATE\\n' > \"$f\"; chmod 600 \"$f\"\n" + + "printf 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA %s\\n' \"$c\" > \"$f.pub\"\n" + if err := os.WriteFile(filepath.Join(bin, "ssh-keygen"), []byte(stub), 0o755); err != nil { + t.Fatal(err) + } + path = bin + string(os.PathListSeparator) + path + t.Log("no ssh-keygen on this host: using a stand-in") + } + + cmd := exec.Command(os.Args[0], "-test.run=^$") + cmd.Env = append(os.Environ(), bundleVar+"=1", "PATH="+path, "MESH_OPERATOR_HOME="+home, + "MESH_SERVED_MODULE=ssh-client", "MESH_MODULE=ssh-client", "MESH_NODE=test") + in, _ := cmd.StdinPipe() + out, _ := cmd.StdoutPipe() + cmd.Stderr = keysLog{t} + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + r.in = in + go r.read(out) + t.Cleanup(func() { _ = in.Close(); _ = cmd.Wait() }) + + key := "test." + account.Username + var raw []byte + for deadline := time.Now().Add(60 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + if e, err := r.bucket("keys").Get(context.Background(), key); err == nil { + raw = e.Value() + break + } + } + if raw == nil { + t.Fatalf("no entry %s in the keys state within 60 s", key) + } + var e KeyEntry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(e.Public, "ssh-ed25519 ") || e.Node != "test" || e.Account != account.Username || e.Kind != "machine" || !strings.HasPrefix(e.Fingerprint, "SHA256:") { + t.Errorf("entry = %s", raw) + } + if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") { + t.Errorf("the entry carries private material: %s", raw) + } + info, err := os.Stat(filepath.Join(home, ".ssh", KeyFile)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("the private key is %04o, want 0600", info.Mode().Perm()) + } +} diff --git a/modules/ssh-client/cmd/ssh-client-tools/keykeeper_test.go b/modules/ssh-client/cmd/ssh-client-tools/keykeeper_test.go new file mode 100644 index 00000000..0ceb52e1 --- /dev/null +++ b/modules/ssh-client/cmd/ssh-client-tools/keykeeper_test.go @@ -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") + } +} diff --git a/modules/ssh-client/cmd/ssh-client-tools/main.go b/modules/ssh-client/cmd/ssh-client-tools/main.go index 4cb5c15e..27b1783f 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/main.go +++ b/modules/ssh-client/cmd/ssh-client-tools/main.go @@ -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 +} diff --git a/modules/ssh-client/cmd/ssh-client-tools/templates_test.go b/modules/ssh-client/cmd/ssh-client-tools/templates_test.go index 7d0718b5..ea270636 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/templates_test.go +++ b/modules/ssh-client/cmd/ssh-client-tools/templates_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -62,3 +63,115 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { t.Errorf("the build packs files/ into %d archives, not one", packs) } } + +// Issue 955, area S2: the module keeps the keys state and requires the forge's account provision. It declares +// no provision `ssh-key`: the state serves the public half (novox/hq ADR 0400 §1). +func TestManifestKeepsKeysAndRequiresForgeAccount(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + State []string `json:"state"` + Requires []any `json:"requires"` + Provides []any `json:"provides"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if len(m.State) != 1 || m.State[0] != "keys" { + t.Errorf("state = %v, want [keys]", m.State) + } + if len(m.Requires) != 1 || m.Requires[0] != "forge-account" { + t.Errorf("requires = %v, want [forge-account]", m.Requires) + } + if len(m.Provides) != 0 { + t.Errorf("provides = %v, want none (no ssh-key provision yet)", m.Provides) + } +} + +// Issue 955, area S3: the three files the forge account fills in — known_hosts, the forge's Host, git's rewrite. +var sampleBound = map[string]string{ + "known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3Nza", + "ssh_host": "novox.internal", + "ssh_port": "222", + "ssh_base": "ssh://git@novox.internal:222/", + "https_base": "https://git.novox.be/", +} + +// renderBound fills ${bound:forge-account:} with the sample values, as the provisioner client does. +func renderBound(t *testing.T, s string) string { + t.Helper() + for k, v := range sampleBound { + s = strings.ReplaceAll(s, "${bound:forge-account:"+k+"}", v) + } + if strings.Contains(s, "${") { + t.Errorf("unfilled placeholder in %q", s) + } + return s +} + +func resourceByPath(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if r["path"] == path { + if r["owner"] != "${machine:account}" { + t.Errorf("%s is owned by %v, not the machine's account", path, r["owner"]) + } + return r + } + } + t.Fatalf("no resource at %s", path) + return nil +} + +func TestForgeFilesRenderFromTheBoundValues(t *testing.T) { + home := "${machine:account-home}" + kh := resourceByPath(t, home+"/.ssh/known_hosts") + if kh["into"] != "block" { + t.Errorf("known_hosts is %v, want a block", kh["into"]) + } + if got := strings.TrimSpace(renderBound(t, templateText(t, kh))); got != "[novox.internal]:222 ssh-ed25519 AAAAC3Nza" { + t.Errorf("known_hosts block = %q", got) + } + + forge := resourceByPath(t, home+"/.ssh/config.d/10-forge") + if forge["into"] != nil { + t.Errorf("10-forge is a block, want the whole file") + } + want := "Host novox.internal\n Port 222\n User git\n IdentityFile ~/.ssh/mesh_ed25519\n IdentitiesOnly yes\n" + if got := renderBound(t, templateText(t, forge)); got != want { + t.Errorf("10-forge = %q, want %q", got, want) + } + + resourceByPath(t, home+"/.config/git") // the directory + g := resourceByPath(t, home+"/.config/git/config") + if g["into"] != "block" { + t.Errorf("git config is %v, want a block", g["into"]) + } + wantGit := "[url \"ssh://git@novox.internal:222/\"]\n\tinsteadOf = https://git.novox.be/\n" + if got := renderBound(t, templateText(t, g)); got != wantGit { + t.Errorf("git config block = %q, want %q", got, wantGit) + } +} + +// The mesh's region in ~/.ssh/config brings in config.d/*, so 10-forge is read. +func TestConfigIncludesTheDropInDirectory(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "config")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "Include ~/.ssh/config.d/*") { + t.Errorf("files/config does not include config.d/*") + } +} diff --git a/modules/ssh-client/files/forge b/modules/ssh-client/files/forge new file mode 100644 index 00000000..2da188cf --- /dev/null +++ b/modules/ssh-client/files/forge @@ -0,0 +1,5 @@ +Host ${bound:forge-account:ssh_host} + Port ${bound:forge-account:ssh_port} + User git + IdentityFile ~/.ssh/mesh_ed25519 + IdentitiesOnly yes diff --git a/modules/ssh-client/files/gitconfig b/modules/ssh-client/files/gitconfig new file mode 100644 index 00000000..d13ccb34 --- /dev/null +++ b/modules/ssh-client/files/gitconfig @@ -0,0 +1,2 @@ +[url "${bound:forge-account:ssh_base}"] + insteadOf = ${bound:forge-account:https_base} diff --git a/modules/ssh-client/files/known_hosts b/modules/ssh-client/files/known_hosts new file mode 100644 index 00000000..aa12b501 --- /dev/null +++ b/modules/ssh-client/files/known_hosts @@ -0,0 +1 @@ +${bound:forge-account:known_host} diff --git a/modules/ssh-client/go.mod b/modules/ssh-client/go.mod index b086b90b..9be3c232 100644 --- a/modules/ssh-client/go.mod +++ b/modules/ssh-client/go.mod @@ -1,5 +1,22 @@ module sshclient -go 1.22 +go 1.25.0 -require git.novox.be/novox/mesh-sdk/go v0.1.6 +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 + github.com/nats-io/nats-server/v2 v2.11.17 + github.com/nats-io/nats.go v1.51.0 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/time v0.15.0 // indirect +) diff --git a/modules/ssh-client/go.sum b/modules/ssh-client/go.sum index 0dd60617..f84463ba 100644 --- a/modules/ssh-client/go.sum +++ b/modules/ssh-client/go.sum @@ -1,2 +1,27 @@ -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= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= +github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/modules/ssh-client/module.json b/modules/ssh-client/module.json index 5bc3c2e2..44cc6086 100644 --- a/modules/ssh-client/module.json +++ b/modules/ssh-client/module.json @@ -11,6 +11,12 @@ "ssh_client_known_host", "ssh_client_test" ], + "requires": [ + "forge-account" + ], + "state": [ + "keys" + ], "data": { "own": [ { @@ -45,6 +51,39 @@ "into": "block", "at": "start", "template": "files/config" + }, + { + "id": "known-hosts", + "type": "file", + "path": "${machine:account-home}/.ssh/known_hosts", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "template": "files/known_hosts" + }, + { + "id": "forge-host", + "type": "file", + "path": "${machine:account-home}/.ssh/config.d/10-forge", + "owner": "${machine:account}", + "mode": "0600", + "template": "files/forge" + }, + { + "id": "git-config-dir", + "type": "directory", + "path": "${machine:account-home}/.config/git", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "git-config", + "type": "file", + "path": "${machine:account-home}/.config/git/config", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "template": "files/gitconfig" } ], "facts": {