diff --git a/modules/mesh-build-go/buildstep/fetch.go b/modules/mesh-build-go/buildstep/fetch.go index 4135bdbab..c61150f2f 100644 --- a/modules/mesh-build-go/buildstep/fetch.go +++ b/modules/mesh-build-go/buildstep/fetch.go @@ -9,6 +9,7 @@ import ( "regexp" "strings" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -71,7 +72,7 @@ func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit, full := append([]string{"-c", "credential.helper=", "-C", dir}, args...) r := run(ctx, env, "git", full...) if r.Status != 0 || r.Err != "" { - return "", fmt.Errorf("git %s: %s", args[0], forgekey.Said(r.Err+"\n"+r.Stderr)) + return "", fmt.Errorf("git %s: %s", args[0], dforgekey.Said(r.Err+"\n"+r.Stderr)) } return r.Stdout, nil } @@ -132,7 +133,7 @@ func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, comm if verb == "-C" && len(args) > 2 { // the mirror's commands are named by what they do, not by -C verb = args[2] } - return fmt.Errorf("git %s: %s", verb, forgekey.Said(r.Err+"\n"+r.Stderr)) + return fmt.Errorf("git %s: %s", verb, dforgekey.Said(r.Err+"\n"+r.Stderr)) } return nil } diff --git a/modules/mesh-build-go/buildstep/merge.go b/modules/mesh-build-go/buildstep/merge.go index 86384b6d2..958750c62 100644 --- a/modules/mesh-build-go/buildstep/merge.go +++ b/modules/mesh-build-go/buildstep/merge.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -55,7 +56,7 @@ func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, comm full := append([]string{"-c", "credential.helper=", "-C", dir}, args...) r := run(ctx, env, "git", full...) if r.Status != 0 || r.Err != "" { - return "", fmt.Errorf("git %s: %s", args[0], forgekey.Said(r.Err+"\n"+r.Stderr)) + return "", fmt.Errorf("git %s: %s", args[0], dforgekey.Said(r.Err+"\n"+r.Stderr)) } return r.Stdout, nil } diff --git a/modules/mesh-build-go/buildstep/trees.go b/modules/mesh-build-go/buildstep/trees.go index b47fef658..1b9b0b910 100644 --- a/modules/mesh-build-go/buildstep/trees.go +++ b/modules/mesh-build-go/buildstep/trees.go @@ -11,6 +11,7 @@ import ( "strings" sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -26,7 +27,7 @@ var lockFiles = []string{"go.sum", "package-lock.json"} func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, commit string) (map[string]sdkstep.ModuleTree, error) { r := run(ctx, env, "git", "-C", dir, "ls-files", "--", "module.json", "*/module.json") if r.Status != 0 || r.Err != "" { - return nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr)) + return nil, fmt.Errorf("git ls-files: %s", dforgekey.Said(r.Err+"\n"+r.Stderr)) } trees := map[string]sdkstep.ModuleTree{} for _, l := range strings.Split(r.Stdout, "\n") { @@ -40,7 +41,7 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co } t := run(ctx, env, "git", "-C", dir, "rev-parse", spec) if t.Status != 0 || t.Err != "" { - return nil, fmt.Errorf("git rev-parse %s: %s", spec, forgekey.Said(t.Err+"\n"+t.Stderr)) + return nil, fmt.Errorf("git rev-parse %s: %s", spec, dforgekey.Said(t.Err+"\n"+t.Stderr)) } mt := sdkstep.ModuleTree{Tree: strings.TrimSpace(t.Stdout)} raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(l))) @@ -86,7 +87,7 @@ func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, for _, round := range unnested(full) { r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, round...)...) if r.Status != 0 || r.Err != "" { - return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, forgekey.Said(r.Err+"\n"+r.Stderr)) + return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, dforgekey.Said(r.Err+"\n"+r.Stderr)) } for _, l := range strings.Split(r.Stdout, "\x00") { meta, name, ok := strings.Cut(l, "\t") diff --git a/modules/mesh-build-go/cmd/mesh-build-go/candidate.go b/modules/mesh-build-go/cmd/mesh-build-go/candidate.go index 27f43d778..71a6f287a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/candidate.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/candidate.go @@ -14,7 +14,7 @@ import ( "strings" "sync" - "git.novox.be/novox/mesh-sdk/go/forgekey" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" ) // The runners of a digest or test step: this installed program, or the candidate's compiled from the workspace. @@ -111,7 +111,7 @@ func (m *Module) mergeCommit(ctx context.Context, workspace string) (string, err r := m.Run(ctx, nil, "git", "-C", filepath.Join(m.Work, workspace, "tree"), "rev-parse", "HEAD") commit := strings.TrimSpace(r.Stdout) if r.Status != 0 || r.Err != "" || commit == "" { - return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", forgekey.Said(r.Err+"\n"+r.Stderr)) + return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", dforgekey.Said(r.Err+"\n"+r.Stderr)) } return commit, nil } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go index 361cef2c1..687de0320 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go @@ -20,7 +20,7 @@ import ( "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" - "git.novox.be/novox/mesh-sdk/go/forgekey" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" ) var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`) @@ -274,7 +274,7 @@ func (m *Module) downloadAndVerify(ctx context.Context, dir, d string) error { func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) { r := m.Run(ctx, nil, "git", "-C", tree, "ls-files", "--", "go.mod", "*/go.mod") if r.Status != 0 || r.Err != "" { - return nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr)) + return nil, fmt.Errorf("git ls-files: %s", dforgekey.Said(r.Err+"\n"+r.Stderr)) } dirs := []string{} for _, l := range strings.Split(r.Stdout, "\n") { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go index f98fce5a0..adeda47db 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go @@ -14,6 +14,7 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -237,7 +238,7 @@ func TestTheKeeperWritesTheModulesEntryWithKindBuilder(t *testing.T) { if !ok || len(st.puts) != 1 { t.Fatalf("entries = %v, want one at ace.%s", st.puts, forgeModule) } - var e forgekey.KeyEntry + var e sshkeys.KeyEntry if err := json.Unmarshal(raw, &e); err != nil { t.Fatal(err) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/isolate.go b/modules/mesh-build-go/cmd/mesh-build-go/isolate.go index a74fa4004..946fab4eb 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/isolate.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/isolate.go @@ -16,6 +16,7 @@ import ( "strings" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -64,7 +65,7 @@ func (m *Module) stepRunner(workspace string, write bool) (forgekey.Runner, erro func ProbeBubblewrap(ctx context.Context) (string, error) { ctx, cancel := context.WithTimeout(ctx, ProbeTimeout) defer cancel() - r := forgekey.ExecRunnerClean(0)(ctx, []string{"PATH=" + defaultPath}, buildstep.Bubblewrap, "--version") + r := dforgekey.ExecRunnerClean(0)(ctx, []string{"PATH=" + defaultPath}, buildstep.Bubblewrap, "--version") if r.Status != 0 || r.Err != "" { return "", fmt.Errorf("`%s --version` failed: %s", buildstep.Bubblewrap, firstLine(r.Err+"\n"+r.Stderr)) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/keys.go b/modules/mesh-build-go/cmd/mesh-build-go/keys.go index fb93a0de9..ba3baff9a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/keys.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/keys.go @@ -13,14 +13,16 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // keeperFor is this module's keeper: go/forgekey's, of kind builder, for account in home on node, whose entry lies // at . of the keys state. -func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper { - return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindBuilder, Run: run, State: state, Now: time.Now} +func keeperFor(home, node, account string, run forgekey.Runner, state sshkeys.KeyPutter) *sshkeys.KeyKeeper { + return &sshkeys.KeyKeeper{Home: home, Node: node, Account: account, Kind: dforgekey.KindBuilder, Run: run, State: state, Now: time.Now} } // startKeeper runs the keeper when the runtime launched this process for a node: outside a launch — a build, a diff --git a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go index c199de784..1fd98b495 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go @@ -51,6 +51,10 @@ func resource(t *testing.T, m catalogue.Manifest, id string) map[string]any { // packages. func TestManifestDeclaresTheAccountTheCapabilityAndThePackages(t *testing.T) { m := readManifest(t) + // The key keeper is ssh-client's published package, taken by its series (ADR 0634 §1). + if m.Packages["sshkeys"] != "0.1" { + t.Errorf("packages %v: want sshkeys of series 0.1", m.Packages) + } if m.Module != "mesh-build-go" || m.RunsAs != "mesh-build-go" { t.Errorf("module %q runs as %q, want mesh-build-go as itself", m.Module, m.RunsAs) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/mirror.go b/modules/mesh-build-go/cmd/mesh-build-go/mirror.go index 2f0420d61..7c3ab5633 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/mirror.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/mirror.go @@ -9,7 +9,7 @@ import ( "syscall" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/forgekey" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" ) // mirrorLocks hold one mutex per mirror of this process; the lock file beside the mirror holds off another process. @@ -68,7 +68,7 @@ func (m *Module) dissociate(ctx context.Context, dir string) error { return nil // no mirror was used } if r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-C", dir, "repack", "-a", "-d", "-q"); r.Status != 0 || r.Err != "" { - return fmt.Errorf("git repack in %s: %s", dir, forgekey.Said(r.Err+"\n"+r.Stderr)) + return fmt.Errorf("git repack in %s: %s", dir, dforgekey.Said(r.Err+"\n"+r.Stderr)) } return os.Remove(alternates) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins.go b/modules/mesh-build-go/cmd/mesh-build-go/pins.go index f51f52398..1bed3a126 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins.go @@ -17,7 +17,7 @@ import ( "regexp" "strings" - "git.novox.be/novox/mesh-sdk/go/forgekey" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" ) const forgePrefix = "git.novox.be/novox/" @@ -140,7 +140,7 @@ func (m *Module) placePin(ctx context.Context, root string, p pin, failure strin r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-c", "credential.helper=", "ls-remote", m.remote(repository), "refs/pull/*/head") if r.Status != 0 || r.Err != "" { // The forge did not answer: retaken, with its own cause, never held as a refusal until the module changes (issue 1748). - return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s failed (a download failure that may be retaken): %s", p.module, p.short, repository, forgekey.Said(r.Err+"\n"+r.Stderr)) + return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s failed (a download failure that may be retaken): %s", p.module, p.short, repository, dforgekey.Said(r.Err+"\n"+r.Stderr)) } commit, ref := "", "" for _, l := range strings.Split(r.Stdout, "\n") { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/probe.go b/modules/mesh-build-go/cmd/mesh-build-go/probe.go index f1a481269..13be6f6bd 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/probe.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/probe.go @@ -7,6 +7,7 @@ import ( "strings" "time" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -18,7 +19,7 @@ const ProbeTimeout = 15 * time.Second // module builds with, and the third word of its answer is that version. It downloads nothing and needs no network. // The environment is built from scratch like a step's (StepRunner), so the module's bus credentials are not in it. func ProbeGo(home, cache string) func(ctx context.Context) (string, error) { - run := forgekey.ExecRunnerClean(0) + run := dforgekey.ExecRunnerClean(0) return func(ctx context.Context) (string, error) { return probeGo(ctx, run, home, cache) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/runner.go b/modules/mesh-build-go/cmd/mesh-build-go/runner.go index 691ef6f58..215204e9a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/runner.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/runner.go @@ -4,6 +4,7 @@ import ( "context" "os" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -14,7 +15,7 @@ import ( // that base given; a command is bound by its context alone (the bound 0), so a fetch may outlast a tool call's thirty // seconds (the step has its own context). func StepRunner(home string) forgekey.Runner { - run := forgekey.ExecRunnerClean(0) + run := dforgekey.ExecRunnerClean(0) return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { return run(ctx, append(baseEnv(home), env...), name, args...) } diff --git a/modules/mesh-build-go/go.mod b/modules/mesh-build-go/go.mod index af61acc8f..6828a3633 100644 --- a/modules/mesh-build-go/go.mod +++ b/modules/mesh-build-go/go.mod @@ -97,3 +97,7 @@ require ( ) replace git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep => ./buildstep + +require git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys => ../ssh-client/sshkeys diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index c41d8622f..799bf3c61 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -1,6 +1,9 @@ { "module": "mesh-build-go", "version": "1", + "packages": { + "sshkeys": "0.1" + }, "moved": true, "runs-as": "mesh-build-go", "capabilities": [ diff --git a/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go b/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go index bfaf3fbb2..553d1e616 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go @@ -14,6 +14,7 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -235,7 +236,7 @@ func TestTheKeeperWritesTheModulesEntryWithKindBuilder(t *testing.T) { if !ok || len(st.puts) != 1 { t.Fatalf("entries = %v, want one at ace.%s", st.puts, forgeModule) } - var e forgekey.KeyEntry + var e sshkeys.KeyEntry if err := json.Unmarshal(raw, &e); err != nil { t.Fatal(err) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/keys.go b/modules/mesh-build-image/cmd/mesh-build-image/keys.go index 85f56c234..5793fab67 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/keys.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/keys.go @@ -13,14 +13,16 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // keeperFor is this module's keeper: go/forgekey's, of kind builder, for account in home on node, whose entry lies // at . of the keys state. -func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper { - return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindBuilder, Run: run, State: state, Now: time.Now} +func keeperFor(home, node, account string, run forgekey.Runner, state sshkeys.KeyPutter) *sshkeys.KeyKeeper { + return &sshkeys.KeyKeeper{Home: home, Node: node, Account: account, Kind: dforgekey.KindBuilder, Run: run, State: state, Now: time.Now} } // startKeeper runs the keeper when the runtime launched this process for a node: outside a launch — a build, a diff --git a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go index 2727e2b63..8e4f161ca 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go @@ -53,6 +53,10 @@ func resource(t *testing.T, m catalogue.Manifest, id string) map[string]any { // openssh; buildah is not its package (the buildah module's alone). func TestManifestDeclaresTheAccountTheCapabilityAndThePackages(t *testing.T) { m := readManifest(t) + // The key keeper is ssh-client's published package, taken by its series (ADR 0634 §1). + if m.Packages["sshkeys"] != "0.1" { + t.Errorf("packages %v: want sshkeys of series 0.1", m.Packages) + } if m.Module != "mesh-build-image" || m.RunsAs != "mesh-build-image" { t.Errorf("module %q runs as %q, want mesh-build-image as itself", m.Module, m.RunsAs) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/runner.go b/modules/mesh-build-image/cmd/mesh-build-image/runner.go index 717d4f0ac..d11857687 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/runner.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/runner.go @@ -4,6 +4,7 @@ import ( "context" "os" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -14,7 +15,7 @@ import ( // that base given; a command is bound by its context alone (the bound 0), so a fetch may outlast a tool call's thirty // seconds (the step has its own context). func StepRunner(home string) forgekey.Runner { - run := forgekey.ExecRunnerClean(0) + run := dforgekey.ExecRunnerClean(0) return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { return run(ctx, append(baseEnv(home), env...), name, args...) } diff --git a/modules/mesh-build-image/go.mod b/modules/mesh-build-image/go.mod index f5288beab..34afdabbe 100644 --- a/modules/mesh-build-image/go.mod +++ b/modules/mesh-build-image/go.mod @@ -34,3 +34,7 @@ require ( ) replace git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep => ../mesh-build-go/buildstep + +require git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys => ../ssh-client/sshkeys diff --git a/modules/mesh-build-image/module.json b/modules/mesh-build-image/module.json index ebe66b7ee..737964a9b 100644 --- a/modules/mesh-build-image/module.json +++ b/modules/mesh-build-image/module.json @@ -1,6 +1,9 @@ { "module": "mesh-build-image", "version": "1", + "packages": { + "sshkeys": "0.1" + }, "moved": true, "runs-as": "mesh-build-image", "capabilities": [ diff --git a/modules/mesh-build-node/cmd/mesh-build-node/fetch.go b/modules/mesh-build-node/cmd/mesh-build-node/fetch.go index b21361a4a..172ef744a 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/fetch.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/fetch.go @@ -25,6 +25,7 @@ import ( "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -215,7 +216,7 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, group func (m *Module) typeScriptModules(ctx context.Context, tree string) (dirs []string, packages map[string]bool, err error) { r := m.Run(ctx, m.baseEnv(), "git", "-C", tree, "ls-files", "--", "module.json", "*/module.json") if r.Status != 0 || r.Err != "" { - return nil, nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr)) + return nil, nil, fmt.Errorf("git ls-files: %s", dforgekey.Said(r.Err+"\n"+r.Stderr)) } packages = map[string]bool{} for _, l := range strings.Split(r.Stdout, "\n") { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go b/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go index c8d58beb4..76bda1a28 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -269,7 +270,7 @@ func TestNoCommandOfTheStepSeesATokenOfTheProcess(t *testing.T) { } } // The runner main wires: what it hands a command is exactly what the step gave. - ran := forgekey.ExecRunnerClean(10*time.Second)(context.Background(), append(m.baseEnv(), "ONLY=this"), "env") + ran := dforgekey.ExecRunnerClean(10*time.Second)(context.Background(), append(m.baseEnv(), "ONLY=this"), "env") if ran.Status != 0 || strings.Contains(ran.Stdout, "hunter") || !strings.Contains(ran.Stdout, "ONLY=this") { t.Errorf("the clean runner printed %q", ran.Stdout) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/forge_test.go b/modules/mesh-build-node/cmd/mesh-build-node/forge_test.go index 030e223d0..26f8dfabb 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/forge_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/forge_test.go @@ -14,6 +14,7 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -235,7 +236,7 @@ func TestTheKeeperWritesTheModulesEntryWithKindBuilder(t *testing.T) { if !ok || len(st.puts) != 1 { t.Fatalf("entries = %v, want one at ace.%s", st.puts, forgeModule) } - var e forgekey.KeyEntry + var e sshkeys.KeyEntry if err := json.Unmarshal(raw, &e); err != nil { t.Fatal(err) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/isolate.go b/modules/mesh-build-node/cmd/mesh-build-node/isolate.go index b00afcc35..86d012bff 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/isolate.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/isolate.go @@ -18,6 +18,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -53,7 +54,7 @@ func (m *Module) stepRunner(root string, write bool) (forgekey.Runner, error) { func ProbeBubblewrap(ctx context.Context) (string, error) { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - r := forgekey.ExecRunnerClean(0)(ctx, []string{"PATH=" + defaultPath}, buildstep.Bubblewrap, "--version") + r := dforgekey.ExecRunnerClean(0)(ctx, []string{"PATH=" + defaultPath}, buildstep.Bubblewrap, "--version") if r.Status != 0 || r.Err != "" { return "", fmt.Errorf("`%s --version` failed: %s", buildstep.Bubblewrap, firstLine(r.Err+"\n"+r.Stderr)) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/keys.go b/modules/mesh-build-node/cmd/mesh-build-node/keys.go index 9c92386d7..fa4aaa1a1 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/keys.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/keys.go @@ -13,14 +13,16 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // keeperFor is this module's keeper: go/forgekey's, of kind builder, for account in home on node, whose entry lies // at . of the keys state. -func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper { - return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindBuilder, Run: run, State: state, Now: time.Now} +func keeperFor(home, node, account string, run forgekey.Runner, state sshkeys.KeyPutter) *sshkeys.KeyKeeper { + return &sshkeys.KeyKeeper{Home: home, Node: node, Account: account, Kind: dforgekey.KindBuilder, Run: run, State: state, Now: time.Now} } // startKeeper runs the keeper when the runtime launched this process for a node: outside a launch — a build, a diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index 60dd9c8ec..3788d68d4 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -15,9 +15,9 @@ import ( "strings" "time" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/forgekey" ) // CacheDir is the cache directory, outside the account's home (ADR 0466 §1; module.json resource `cache`). @@ -42,7 +42,7 @@ func main() { Node: strings.TrimSpace(os.Getenv("MESH_NODE")), Reload: func() (Recipe, error) { return ReadRecipe(paths.LockPath()) }, Probe: ProbeNode, - Toolchain: &Toolchain{Paths: paths, Run: forgekey.ExecRunnerClean(0), Registry: registry}, + Toolchain: &Toolchain{Paths: paths, Run: dforgekey.ExecRunnerClean(0), Registry: registry}, Toolchains: stdio.State(delivery.ToolchainsState).Put, Emit: func(t string, b any) error { return stdio.Emit(t, b) }, Steps: stdio.State(delivery.StepsState).Put, @@ -51,7 +51,7 @@ func main() { ReadPauses: readOf(stdio.State(delivery.PausesState)), ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)), Work: WorkDir, - Run: forgekey.ExecRunnerClean(0), + Run: dforgekey.ExecRunnerClean(0), Ctx: ctx, Say: logf, Bubblewrap: ProbeBubblewrap, diff --git a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go index 5987c4ab8..e829b2cc0 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go @@ -46,6 +46,10 @@ func resource(t *testing.T, m catalogue.Manifest, id string) map[string]any { // home at mode 0700, requires user-namespaces and declares the four packages and carries toolchain/package-lock.json. func TestManifestDeclaresTheAccountTheCapabilityAndThePackages(t *testing.T) { m := readManifest(t) + // The key keeper is ssh-client's published package, taken by its series (ADR 0634 §1). + if m.Packages["sshkeys"] != "0.1" { + t.Errorf("packages %v: want sshkeys of series 0.1", m.Packages) + } if m.Module != "mesh-build-node" || m.RunsAs != "mesh-build-node" { t.Errorf("module %q runs as %q, want mesh-build-node as itself", m.Module, m.RunsAs) } diff --git a/modules/mesh-build-node/go.mod b/modules/mesh-build-node/go.mod index 63269c655..e9b7fff74 100644 --- a/modules/mesh-build-node/go.mod +++ b/modules/mesh-build-node/go.mod @@ -94,3 +94,7 @@ require ( ) replace git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep => ../mesh-build-go/buildstep + +require git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys => ../ssh-client/sshkeys diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index a15225b60..574be27f8 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -1,6 +1,9 @@ { "module": "mesh-build-node", "version": "1", + "packages": { + "sshkeys": "0.1" + }, "moved": true, "runs-as": "mesh-build-node", "capabilities": [ diff --git a/modules/ssh-client/cmd/ssh-client-tools/client.go b/modules/ssh-client/cmd/ssh-client-tools/client.go index 3c2c51071..3eb2dd661 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/client.go +++ b/modules/ssh-client/cmd/ssh-client-tools/client.go @@ -18,6 +18,7 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -209,7 +210,7 @@ func (c *Client) Keys(ctx context.Context) ([]Key, error) { k.AgeDays = int(c.Now().Sub(info.ModTime()).Hours() / 24) } if pub, err := os.ReadFile(path + ".pub"); err == nil { - if pk, err := forgekey.ParseKeyLine(string(pub)); err == nil { + if pk, err := sshkeys.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 { @@ -227,7 +228,7 @@ func (c *Client) Keys(ctx context.Context) ([]Key, error) { var derived string k.Passphrase, derived = c.passphrase(ctx, path) if derived != "" { - if pk, err := forgekey.ParseKeyLine(derived); err == nil { + if pk, err := sshkeys.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) } @@ -266,7 +267,7 @@ func (c *Client) passphrase(ctx context.Context, path string) (string, string) { // AuthorizedKey is one line of authorized_keys, by fingerprint. type AuthorizedKey struct { - forgekey.PublicKey + sshkeys.PublicKey Line int `json:"line"` InMesh bool `json:"in_mesh_region,omitempty"` } @@ -311,7 +312,7 @@ func (c *Client) readAuthorized() ([]AuthorizedKey, []int, error) { case t == "" || strings.HasPrefix(t, "#"): continue } - k, err := forgekey.ParseKeyLine(t) + k, err := sshkeys.ParseKeyLine(t) if err != nil { bad = append(bad, n+1) continue @@ -399,26 +400,26 @@ func atomicWrite(path string, data []byte, mode fs.FileMode) error { // ---- 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) ([]forgekey.PublicKey, error) { +func (c *Client) knownFor(ctx context.Context, host string, port int) ([]sshkeys.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 []forgekey.PublicKey{}, nil + return []sshkeys.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 := []forgekey.PublicKey{} + keys := []sshkeys.PublicKey{} for _, l := range strings.Split(r.Stdout, "\n") { if l = strings.TrimSpace(l); l == "" || strings.HasPrefix(l, "#") { continue } - if k, err := forgekey.ParseKeyLine(l); err == nil { + if k, err := sshkeys.ParseKeyLine(l); err == nil { k.Comment, k.Options = "", "" keys = append(keys, k) } @@ -427,17 +428,17 @@ func (c *Client) knownFor(ctx context.Context, host string, port int) ([]forgeke } // scan asks a host for its keys now. -func (c *Client) scan(ctx context.Context, host string, port int) ([]forgekey.PublicKey, []string, error) { +func (c *Client) scan(ctx context.Context, host string, port int) ([]sshkeys.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 := []forgekey.PublicKey{}, []string{} + keys, lines := []sshkeys.PublicKey{}, []string{} for _, l := range strings.Split(r.Stdout, "\n") { if l = strings.TrimSpace(l); l == "" || strings.HasPrefix(l, "#") { continue } - if k, err := forgekey.ParseKeyLine(l); err == nil { + if k, err := sshkeys.ParseKeyLine(l); err == nil { k.Comment, k.Options = "", "" keys = append(keys, k) lines = append(lines, l) @@ -457,7 +458,7 @@ func orElse(s, def string) string { } // compare says how what is known stands against what the host offers now. -func compare(known, live []forgekey.PublicKey) string { +func compare(known, live []sshkeys.PublicKey) string { if len(known) == 0 { return "not known: the first connection would ask" } diff --git a/modules/ssh-client/cmd/ssh-client-tools/keeper_test.go b/modules/ssh-client/cmd/ssh-client-tools/keeper_test.go index 0be304733..c6ba1045d 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/keeper_test.go +++ b/modules/ssh-client/cmd/ssh-client-tools/keeper_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -60,7 +61,7 @@ func TestTheBundlesKeeperIsForgekeysOfKindMachineWithItsEntryAtNodeAccount(t *te if !ok || len(st.puts) != 1 { t.Fatalf("entries = %v, want one at g14.nox", st.puts) } - var e forgekey.KeyEntry + var e sshkeys.KeyEntry if err := json.Unmarshal(raw, &e); err != nil { t.Fatal(err) } 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 index cea182b51..5929de6db 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/keykeeper_bus_test.go +++ b/modules/ssh-client/cmd/ssh-client-tools/keykeeper_bus_test.go @@ -15,7 +15,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/forgekey" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -171,7 +171,7 @@ func TestTheKeyProcessMakesTheKeyAndPutsItsEntryOverTheBus(t *testing.T) { if raw == nil { t.Fatalf("no entry %s in the keys state within 60 s", key) } - var e forgekey.KeyEntry + var e sshkeys.KeyEntry if err := json.Unmarshal(raw, &e); err != nil { t.Fatal(err) } @@ -181,7 +181,7 @@ func TestTheKeyProcessMakesTheKeyAndPutsItsEntryOverTheBus(t *testing.T) { 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", forgekey.KeyFile)) + info, err := os.Stat(filepath.Join(home, ".ssh", sshkeys.KeyFile)) if err != nil { t.Fatal(err) } diff --git a/modules/ssh-client/cmd/ssh-client-tools/main.go b/modules/ssh-client/cmd/ssh-client-tools/main.go index d93b5493b..0777dd0f7 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/main.go +++ b/modules/ssh-client/cmd/ssh-client-tools/main.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -158,16 +159,16 @@ func startKeeper() { // keeperFor is the machine's account's keeper: go/forgekey's, of kind machine (ADR 0400 §1, ADR 0466 §2), whose key // file is ~/.ssh/mesh_ed25519 and whose entry lies at . in the keys state. -func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper { - return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindMachine, Run: run, State: state, Now: time.Now} +func keeperFor(home, node, account string, run forgekey.Runner, state sshkeys.KeyPutter) *sshkeys.KeyKeeper { + return &sshkeys.KeyKeeper{Home: home, Node: node, Account: account, Kind: sshkeys.KindMachine, Run: run, State: state, Now: time.Now} } -// keepSwept sweeps ~/.ssh (sweep.go) now and every forgekey.KeyRound. +// keepSwept sweeps ~/.ssh (sweep.go) now and every sshkeys.KeyRound. func keepSwept(c *Client) { say := func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[ssh-client] "+f+"\n", a...) } for { c.SweepOnce(time.Now(), say) - time.Sleep(forgekey.KeyRound) + time.Sleep(sshkeys.KeyRound) } } diff --git a/modules/ssh-client/cmd/ssh-client-tools/ssh_test.go b/modules/ssh-client/cmd/ssh-client-tools/ssh_test.go index 93002138e..fc3d30a7b 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/ssh_test.go +++ b/modules/ssh-client/cmd/ssh-client-tools/ssh_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -183,15 +184,15 @@ func TestAPublicHalfThatIsAnotherKeysIsFound(t *testing.T) { } func TestFingerprintsAreSshKeygens(t *testing.T) { - k, err := forgekey.ParseKeyLine("from=\"10.0.0.0/8,192.0.2.1\",no-pty " + edPub) + k, err := sshkeys.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, _ = forgekey.ParseKeyLine(rsaPub) + k, _ = sshkeys.ParseKeyLine(rsaPub) if k.Fingerprint != rsaFP || k.Bits != 2048 || !strings.Contains(k.Weak, "below 3072") { t.Fatalf("%+v", k) } - if _, err := forgekey.ParseKeyLine("ssh-ed25519 !!!notbase64"); err == nil { + if _, err := sshkeys.ParseKeyLine("ssh-ed25519 !!!notbase64"); err == nil { t.Fatal("garbage accepted") } } diff --git a/modules/ssh-client/cmd/ssh-client-tools/sweep.go b/modules/ssh-client/cmd/ssh-client-tools/sweep.go index b22ea2de8..77fc0546a 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/sweep.go +++ b/modules/ssh-client/cmd/ssh-client-tools/sweep.go @@ -22,20 +22,20 @@ import ( "syscall" "time" - "git.novox.be/novox/mesh-sdk/go/forgekey" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" ) // Declared are the paths under ~/.ssh this module declares (module.json) or its own process makes // (keykeeper.go). A test checks the manifest against this list. var Declared = map[string]bool{ - "config": true, - "known_hosts": true, - "config.d": true, - "config.d/00-mesh": true, - "config.d/10-forge": true, - forgekey.KeyFile: true, - forgekey.KeyFile + ".pub": true, - "authorized_keys": true, + "config": true, + "known_hosts": true, + "config.d": true, + "config.d/00-mesh": true, + "config.d/10-forge": true, + sshkeys.KeyFile: true, + sshkeys.KeyFile + ".pub": true, + "authorized_keys": true, } // SSHReads are the files ssh and sshd read in ~/.ssh by themselves, though this module writes none of @@ -373,7 +373,7 @@ func publicKey(path string) bool { return false } line, _, _ := strings.Cut(string(raw), "\n") - if _, err := forgekey.ParseKeyLine(line); err == nil { + if _, err := sshkeys.ParseKeyLine(line); err == nil { return true } f := strings.Fields(line) diff --git a/modules/ssh-client/cmd/ssh-client-tools/sweep_test.go b/modules/ssh-client/cmd/ssh-client-tools/sweep_test.go index a001c8067..d07a4ff50 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/sweep_test.go +++ b/modules/ssh-client/cmd/ssh-client-tools/sweep_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/forgekey" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" ) // ADR 0415, issue 1017: the sweep keeps ~/.ssh to what this module knows and moves the rest out. @@ -75,7 +75,7 @@ func TestSweepKeepsWhatItKnows(t *testing.T) { t.Fatal(err) } for _, rel := range []string{"config", "known_hosts", "authorized_keys", "config.d/00-mesh", "config.d/10-forge", - forgekey.KeyFile, forgekey.KeyFile + ".pub", "id_ed25519", "id_ed25519.pub", "work_key", "work_key.pub", "work_key-cert.pub", "agent_only.pub", "removed-drone-20261004/id_drone", "authorized_keys2", "id_token"} { + sshkeys.KeyFile, sshkeys.KeyFile + ".pub", "id_ed25519", "id_ed25519.pub", "work_key", "work_key.pub", "work_key-cert.pub", "agent_only.pub", "removed-drone-20261004/id_drone", "authorized_keys2", "id_token"} { if !exists(filepath.Join(dir, rel)) { t.Errorf("%s was moved; it is known", rel) } 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 ccadfd09e..20cbcb184 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/templates_test.go +++ b/modules/ssh-client/cmd/ssh-client-tools/templates_test.go @@ -62,6 +62,16 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { if packs != 1 { t.Errorf("the build packs files/ into %d archives, not one", packs) } + // The key keeper is this module's published package, taken by series (ADR 0634 §1). + published := false + for _, a := range m.Build.Artifacts { + if a["name"] == "sshkeys" && a["kind"] == "package" && a["series"] == "0.1" { + published = true + } + } + if !published { + t.Errorf("no package artifact sshkeys of series 0.1 in the build") + } } // Issue 955, area S2: the module keeps the keys state and requires the forge's account provision. It declares diff --git a/modules/ssh-client/go.mod b/modules/ssh-client/go.mod index 4c18ba173..652ac2526 100644 --- a/modules/ssh-client/go.mod +++ b/modules/ssh-client/go.mod @@ -20,3 +20,9 @@ require ( golang.org/x/sys v0.48.0 // indirect golang.org/x/time v0.16.0 // indirect ) + +require git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys => ./sshkeys + +require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect diff --git a/modules/ssh-client/module.json b/modules/ssh-client/module.json index a6965445a..0baf377f9 100644 --- a/modules/ssh-client/module.json +++ b/modules/ssh-client/module.json @@ -106,6 +106,15 @@ "name": "files", "kind": "archive", "from": "files" + }, + { + "name": "sshkeys", + "kind": "package", + "language": "go", + "paths": [ + "sshkeys" + ], + "series": "0.1" } ] } diff --git a/modules/ssh-client/sshkeys/go.mod b/modules/ssh-client/sshkeys/go.mod new file mode 100644 index 000000000..61d3f0cdd --- /dev/null +++ b/modules/ssh-client/sshkeys/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) diff --git a/modules/ssh-client/sshkeys/keeper.go b/modules/ssh-client/sshkeys/keeper.go new file mode 100644 index 000000000..21194a78b --- /dev/null +++ b/modules/ssh-client/sshkeys/keeper.go @@ -0,0 +1,131 @@ +// Package sshkeys is ssh-client's own published package: the key keeper every module that reaches the forge over SSH +// shares (ssh-client for a machine's accounts, a build module for its own; design 47 R4c-3, ADR 0466 §2; issue 1469) +// and the reading of a public key line. A module takes it by series (ADR 0634 §1: the owner's package). +package sshkeys + +// 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" + + "git.novox.be/novox/mesh-delivery/sdk/forgekey" + sdk "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// KeyFile is the key's name under ~/.ssh: never one of the operator's own id_* files. +const KeyFile = "mesh_ed25519" + +// The kinds of key (ADR 0400 §1, ADR 0466 §2): a machine's account's own, and a build module's. +const KindMachine = "machine" + +// KindBuilder is the kind of a build module's key, kept with the Delivery domain's names. +const KindBuilder = forgekey.KindBuilder + +// 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) (uint64, error) +} + +// KeyKeeper keeps one account's key and its entry. +type KeyKeeper struct { + Home, Node, Account string + Kind string // KindMachine or KindBuilder + Run sdk.Runner + State KeyPutter + Now func() time.Time + // After waits between rounds; time.After when nil. A test sets it to see which pause was chosen. + After func(time.Duration) <-chan 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: k.Kind, 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", forgekey.Said(r.Err+"\n"+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 + } + after := k.After + if after == nil { + after = time.After + } + select { + case <-ctx.Done(): + return + case <-after(pause): + } + } +} diff --git a/modules/ssh-client/sshkeys/keeper_test.go b/modules/ssh-client/sshkeys/keeper_test.go new file mode 100644 index 000000000..05d8e2935 --- /dev/null +++ b/modules/ssh-client/sshkeys/keeper_test.go @@ -0,0 +1,231 @@ +package sshkeys + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + sdk "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// 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) (uint64, error) { + raw, _ := json.Marshal(value) + if f.puts == nil { + f.puts = map[string][]byte{} + } + f.puts[key] = raw + return uint64(len(f.puts)), 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) sdk.Runner { + return func(ctx context.Context, env []string, name string, args ...string) sdk.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 sdk.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 sdk.Ran{} + } +} + +func newKeeper(t *testing.T, home string, st *fakeState, calls *[][]string) *KeyKeeper { + return &KeyKeeper{Home: home, Node: "g14", Account: "nox", Kind: KindMachine, 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") + } +} + +// 1469/AC1: an empty home, kind builder, account mesh-build-go: ssh-keygen once, for the one file, and the entry +// carries the kind, the public half and its fingerprint. +func TestBuilderKeyIsMadeOnceAndPutWithItsKind(t *testing.T) { + home := t.TempDir() + st, calls := &fakeState{}, &[][]string{} + k := newKeeper(t, home, st, calls) + k.Account, k.Kind = "mesh-build-go", KindBuilder + if err := k.Once(context.Background()); err != nil { + t.Fatal(err) + } + if len(*calls) != 1 || !strings.Contains(strings.Join((*calls)[0], " "), filepath.Join(home, ".ssh", "mesh_ed25519")) { + t.Errorf("ssh-keygen calls = %v, want one for mesh_ed25519 in the home", *calls) + } + var e KeyEntry + if err := json.Unmarshal(st.puts["g14.mesh-build-go"], &e); err != nil { + t.Fatalf("entry under g14.mesh-build-go: %v (%v)", err, st.puts) + } + pub, _ := os.ReadFile(filepath.Join(home, ".ssh", "mesh_ed25519.pub")) + pk, _ := ParseKeyLine(string(pub)) + if e.Kind != "builder" || e.Public != strings.TrimSpace(string(pub)) || e.Fingerprint != pk.Fingerprint || e.Fingerprint == "" { + t.Errorf("entry = %+v", e) + } +} + +// 1469/AC2: an existing key, Once twice: ssh-keygen never, one put. +func TestExistingKeyIsPutOnceAcrossTwoRounds(t *testing.T) { + home := t.TempDir() + os.MkdirAll(filepath.Join(home, ".ssh"), 0o700) + os.WriteFile(filepath.Join(home, ".ssh", "mesh_ed25519"), []byte("mine"), 0o600) + os.WriteFile(filepath.Join(home, ".ssh", "mesh_ed25519.pub"), []byte(ed25519Line(9, "x")+"\n"), 0o644) + st, calls := &countState{}, &[][]string{} + k := newKeeper(t, home, &fakeState{}, calls) + k.State = st + for i := 0; i < 2; i++ { + if err := k.Once(context.Background()); err != nil { + t.Fatal(err) + } + } + if len(*calls) != 0 || st.n != 1 { + t.Errorf("ssh-keygen calls = %d, puts = %d, want 0 and 1", len(*calls), st.n) + } +} + +type countState struct{ n int } + +func (c *countState) Put(string, any) (uint64, error) { c.n++; return uint64(c.n), nil } + +// 1469/AC3: the entry's JSON is ssh-client's own (mesh-catalog modules/ssh-client keykeeper_test.go's keys +// entry: node, account, kind, public, fingerprint, at, in that order); mesh-catalog's gitea provider reads it. +func TestKeyEntryJSONIsSSHClients(t *testing.T) { + raw, err := json.Marshal(KeyEntry{Node: "g14", Account: "nox", Kind: "machine", Public: "ssh-ed25519 AAAA nox@g14 (mesh)", + Fingerprint: "SHA256:abc", At: "2026-10-16T12:00:00Z"}) + if err != nil { + t.Fatal(err) + } + want := `{"node":"g14","account":"nox","kind":"machine","public":"ssh-ed25519 AAAA nox@g14 (mesh)","fingerprint":"SHA256:abc","at":"2026-10-16T12:00:00Z"}` + if string(raw) != want { + t.Errorf("entry JSON = %s\nwant %s", raw, want) + } +} + +type failState struct{} + +func (failState) Put(string, any) (uint64, error) { return 0, errors.New("no grant yet") } + +// 1469/AC4: a failing put is said, and the next look is after KeyRetry, not KeyRound. +func TestKeepSaysAFailedPutAndRetriesSoon(t *testing.T) { + home := t.TempDir() + k := newKeeper(t, home, &fakeState{}, &[][]string{}) + k.State = failState{} + ctx, cancel := context.WithCancel(context.Background()) + var said []string + var pauses []time.Duration + k.After = func(d time.Duration) <-chan time.Time { + pauses = append(pauses, d) + cancel() + return make(chan time.Time) + } + k.Keep(ctx, func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }) + if len(said) != 1 || !strings.Contains(said[0], "no grant yet") { + t.Errorf("said = %v", said) + } + if len(pauses) != 1 || pauses[0] != KeyRetry { + t.Errorf("pauses = %v, want [%v]", pauses, KeyRetry) + } +} diff --git a/modules/ssh-client/sshkeys/keys.go b/modules/ssh-client/sshkeys/keys.go new file mode 100644 index 000000000..da2ddeaea --- /dev/null +++ b/modules/ssh-client/sshkeys/keys.go @@ -0,0 +1,110 @@ +package sshkeys + +// 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 +}