From c8dfbdc8139297c259fb786693db769881127697 Mon Sep 17 00:00:00 2001 From: "fix-955-forge@novox" Date: Fri, 16 Oct 2026 22:41:11 +0200 Subject: [PATCH 01/12] Lift the agent account code into internal/forgeaccount for the forge-account provider (issue 955 F1) --- modules/gitea/cmd/agent-token/token.go | 121 ++------------- .../gitea/internal/forgeaccount/account.go | 145 ++++++++++++++++++ 2 files changed, 158 insertions(+), 108 deletions(-) create mode 100644 modules/gitea/internal/forgeaccount/account.go diff --git a/modules/gitea/cmd/agent-token/token.go b/modules/gitea/cmd/agent-token/token.go index a827312f..e427b632 100644 --- a/modules/gitea/cmd/agent-token/token.go +++ b/modules/gitea/cmd/agent-token/token.go @@ -17,6 +17,7 @@ import ( "golang.org/x/crypto/nacl/box" + "gitea/internal/forgeaccount" "gitea/internal/forgehttp" ) @@ -31,7 +32,7 @@ const ( // TokenName is the token's name in the agent account's list, found and deleted by it at the next mint. TokenName = "mesh-agent-read" // AccountPrefix names an agent's account for its node (ADR 0365 §1). - AccountPrefix = "agent-" + AccountPrefix = forgeaccount.AccountPrefix // vaultModule is the vault's module: its account is `.mesh-vault` (ADR 0259 §8's runs-as). vaultModule = "mesh-vault" // deadline bounds one call, inside the vault's ask. @@ -43,22 +44,12 @@ const ( // the agents team's (a write team on every repository); main takes no push whatever the token (ADR 0398 §2). var TokenScopes = []string{"write:repository", "write:issue"} +// The agents team and its shape are the forgeaccount package's (ADR 0398 §1). const ( - // TeamOrg is the organisation whose repositories are the mesh's. - TeamOrg = "novox" - // TeamName is the one team every agent's account is in (ADR 0398 §1). - TeamName = "agents" + TeamOrg = forgeaccount.TeamOrg + TeamName = forgeaccount.TeamName ) -// teamShape is what the agents team grants: write on code, issues and pull requests of every repository of the -// organisation and no more, the shape of the forge tools' team (tools-account.ts). -func teamShape() map[string]any { - return map[string]any{ - "name": TeamName, "permission": "write", "includes_all_repositories": true, "can_create_org_repo": false, - "units_map": map[string]string{"repo.code": "write", "repo.issues": "write", "repo.pulls": "write"}, - } -} - var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) // fromTheVault refuses every caller but the vault's own account (ADR 0349 clause 10): whoever else could call this @@ -206,10 +197,10 @@ func (m *Minter) Answer(ask Ask) (Answer, error) { continue } account := AccountPrefix + h.Node - if err := m.ensureAccount(ctx, account); err != nil { + if err := m.accounts().EnsureAccount(ctx, account); err != nil { return Answer{}, err } - if err := m.ensureInTeam(ctx, account); err != nil { + if err := m.accounts().EnsureInTeam(ctx, account); err != nil { return Answer{}, err } token, err := m.mint(ctx, account) @@ -274,91 +265,6 @@ func credentialLines(account, token string, origins []string) (string, error) { return strings.Join(lines, "\n") + "\n", nil } -// gitea's account as the admin reads it. -type forgeUser struct { - IsAdmin bool `json:"is_admin"` - Active bool `json:"active"` - ProhibitLogin bool `json:"prohibit_login"` - Restricted bool `json:"restricted"` -} - -// ensureAccount makes the agent's account, plain, when there is none, and puts back what was changed by hand: never -// an administrator, active, able to use its token. Its team is ensureInTeam's. -func (m *Minter) ensureAccount(ctx context.Context, account string) error { - var u forgeUser - err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u) - switch { - case errors.Is(err, forgehttp.ErrNotFound): - password := m.password() - err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users", map[string]any{ - "username": account, "email": account + "@noreply.localhost", "password": password, - "must_change_password": false, "visibility": "private", - }, nil) - password = "" - if err != nil { - return fmt.Errorf("the account %s could not be made: %w", account, err) - } - m.say("made the forge account %s for that machine's agent, in no team yet", account) - return nil - case err != nil: - return fmt.Errorf("the account %s could not be read: %w", account, err) - } - if u.IsAdmin || !u.Active || u.ProhibitLogin { - if err := m.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+account, map[string]any{ - "login_name": account, "source_id": 0, "admin": false, "active": true, "prohibit_login": false, - }, nil); err != nil { - return fmt.Errorf("the account %s could not be made a plain account again: %w", account, err) - } - m.say("the account %s was changed by hand (administrator %t, active %t, login prohibited %t); made a plain account again", - account, u.IsAdmin, u.Active, u.ProhibitLogin) - } - return nil -} - -// ensureInTeam makes the agents team when there is none, puts a team that is there back to its shape, and adds the -// account to it; an account already in it stays (ADR 0398 §1, issue 948). -func (m *Minter) ensureInTeam(ctx context.Context, account string) error { - var id int64 - for page := 1; page <= 20 && id == 0; page++ { - var teams []struct { - ID int64 `json:"id"` - Name string `json:"name"` - } - if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/orgs/%s/teams?limit=50&page=%d", TeamOrg, page), &teams); err != nil { - return fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err) - } - for _, t := range teams { - if t.Name == TeamName { - id = t.ID - } - } - if len(teams) < 50 { - break - } - } - if id != 0 { - if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), teamShape(), nil); err != nil { - return fmt.Errorf("the team %s could not be put back to its shape: %w", TeamName, err) - } - } else { - var made struct { - ID int64 `json:"id"` - } - if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", teamShape(), &made); err != nil { - return fmt.Errorf("the team %s could not be made: %w", TeamName, err) - } - id = made.ID - m.say("made the forge team %s in %s: write on every repository", TeamName, TeamOrg) - } - if id == 0 { - return fmt.Errorf("the forge answered no id for the team %s", TeamName) - } - if err := m.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", id, account), nil, nil); err != nil { - return fmt.Errorf("the account %s could not be added to the team %s: %w", account, TeamName, err) - } - return nil -} - type forgeToken struct { ID int64 `json:"id"` Name string `json:"name"` @@ -392,13 +298,9 @@ func (m *Minter) mint(ctx context.Context, account string) (string, error) { return strings.TrimSpace(made.SHA1), nil } -func (m *Minter) password() string { - if m.Password != nil { - return m.Password() - } - b := make([]byte, 24) - _, _ = rand.Read(b) - return base64.RawURLEncoding.EncodeToString(b) +// accounts is the forge's accounts as this minter reaches them (ADR 0400 §3: one place says what an agent's account is). +func (m *Minter) accounts() *forgeaccount.Accounts { + return &forgeaccount.Accounts{Forge: m.Forge, Password: m.Password, Log: m.Log} } func (m *Minter) say(format string, a ...any) { @@ -408,3 +310,6 @@ func (m *Minter) say(format string, a ...any) { } fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// forgeUser is gitea's account as the admin reads it (the forgeaccount package's). +type forgeUser = forgeaccount.User diff --git a/modules/gitea/internal/forgeaccount/account.go b/modules/gitea/internal/forgeaccount/account.go new file mode 100644 index 00000000..57ec5776 --- /dev/null +++ b/modules/gitea/internal/forgeaccount/account.go @@ -0,0 +1,145 @@ +// Package forgeaccount is the forge accounts the mesh makes for its agents (novox/hq ADR 0365 clauses 1 and 3, ADR +// 0398 §1, ADR 0399, ADR 0400; issue 955): an account of the agent's own, no administrator, in the agents write +// team. Both the token tool (cmd/agent-token) and the forge-account provider use it, so there is one place that says +// what an agent's account is. +package forgeaccount + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/http" + "os" + + "gitea/internal/forgehttp" +) + +const ( + // AccountPrefix names an agent's account for its node (ADR 0365 §1). + AccountPrefix = "agent-" + // TeamOrg is the organisation whose repositories are the mesh's. + TeamOrg = "novox" + // TeamName is the one team every agent's account is in (ADR 0398 §1). + TeamName = "agents" +) + +// teamShape is what the agents team grants: write on code, issues and pull requests of every repository of the +// organisation and no more, the shape of the forge tools' team (tools-account.ts). +func teamShape() map[string]any { + return map[string]any{ + "name": TeamName, "permission": "write", "includes_all_repositories": true, "can_create_org_repo": false, + "units_map": map[string]string{"repo.code": "write", "repo.issues": "write", "repo.pulls": "write"}, + } +} + +// User is gitea's account as the admin reads it. +type User struct { + IsAdmin bool `json:"is_admin"` + Active bool `json:"active"` + ProhibitLogin bool `json:"prohibit_login"` + Restricted bool `json:"restricted"` +} + +// Accounts makes and keeps the agents' accounts with the administrator's login. +type Accounts struct { + Forge forgehttp.Client + // Password makes the password an account is created with: random, sent once and kept nowhere. + Password func() string + Log func(format string, a ...any) +} + +// EnsureAccount makes the agent's account, plain, when there is none, and puts back what was changed by hand: never +// an administrator, active, able to use its token. Its team is EnsureInTeam's. +func (m *Accounts) EnsureAccount(ctx context.Context, account string) error { + var u User + err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u) + switch { + case errors.Is(err, forgehttp.ErrNotFound): + password := m.password() + err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users", map[string]any{ + "username": account, "email": account + "@noreply.localhost", "password": password, + "must_change_password": false, "visibility": "private", + }, nil) + password = "" + if err != nil { + return fmt.Errorf("the account %s could not be made: %w", account, err) + } + m.say("made the forge account %s for that machine's agent, in no team yet", account) + return nil + case err != nil: + return fmt.Errorf("the account %s could not be read: %w", account, err) + } + if u.IsAdmin || !u.Active || u.ProhibitLogin { + if err := m.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+account, map[string]any{ + "login_name": account, "source_id": 0, "admin": false, "active": true, "prohibit_login": false, + }, nil); err != nil { + return fmt.Errorf("the account %s could not be made a plain account again: %w", account, err) + } + m.say("the account %s was changed by hand (administrator %t, active %t, login prohibited %t); made a plain account again", + account, u.IsAdmin, u.Active, u.ProhibitLogin) + } + return nil +} + +// EnsureInTeam makes the agents team when there is none, puts a team that is there back to its shape, and adds the +// account to it; an account already in it stays (ADR 0398 §1, issue 948). +func (m *Accounts) EnsureInTeam(ctx context.Context, account string) error { + var id int64 + for page := 1; page <= 20 && id == 0; page++ { + var teams []struct { + ID int64 `json:"id"` + Name string `json:"name"` + } + if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/orgs/%s/teams?limit=50&page=%d", TeamOrg, page), &teams); err != nil { + return fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err) + } + for _, t := range teams { + if t.Name == TeamName { + id = t.ID + } + } + if len(teams) < 50 { + break + } + } + if id != 0 { + if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), teamShape(), nil); err != nil { + return fmt.Errorf("the team %s could not be put back to its shape: %w", TeamName, err) + } + } else { + var made struct { + ID int64 `json:"id"` + } + if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", teamShape(), &made); err != nil { + return fmt.Errorf("the team %s could not be made: %w", TeamName, err) + } + id = made.ID + m.say("made the forge team %s in %s: write on every repository", TeamName, TeamOrg) + } + if id == 0 { + return fmt.Errorf("the forge answered no id for the team %s", TeamName) + } + if err := m.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", id, account), nil, nil); err != nil { + return fmt.Errorf("the account %s could not be added to the team %s: %w", account, TeamName, err) + } + return nil +} + +func (m *Accounts) password() string { + if m.Password != nil { + return m.Password() + } + b := make([]byte, 24) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func (m *Accounts) say(format string, a ...any) { + if m.Log != nil { + m.Log(format, a...) + return + } + fmt.Fprintf(os.Stderr, format+"\n", a...) +} -- 2.54.0 From 8131c13634687ce4de147ec8c21dcde988748c6c Mon Sep 17 00:00:00 2001 From: "fix-955-forge@novox" Date: Fri, 16 Oct 2026 22:42:04 +0200 Subject: [PATCH 02/12] Register and prune an agent account's mesh SSH keys through the forge API (issue 955 F2) --- modules/gitea/internal/forgeaccount/keys.go | 132 ++++++++++++ .../gitea/internal/forgeaccount/keys_test.go | 192 ++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 modules/gitea/internal/forgeaccount/keys.go create mode 100644 modules/gitea/internal/forgeaccount/keys_test.go diff --git a/modules/gitea/internal/forgeaccount/keys.go b/modules/gitea/internal/forgeaccount/keys.go new file mode 100644 index 00000000..018241b9 --- /dev/null +++ b/modules/gitea/internal/forgeaccount/keys.go @@ -0,0 +1,132 @@ +package forgeaccount + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "gitea/internal/forgehttp" +) + +// TitlePrefix starts the title of every key the mesh registers; a key without it is the operator's and is never +// touched (ADR 0399 §2, ADR 0400). +const TitlePrefix = "mesh:" + +// KeyTitle is the title of a machine account's key: `mesh:.`. +func KeyTitle(node, account string) string { return TitlePrefix + node + "." + account } + +// WantedKey is a public key the mesh wants registered, under its title. +type WantedKey struct { + Title string + // Key is the public key line: type, base64 and a comment. + Key string +} + +// Key is a key the forge holds for an account. +type Key struct { + ID int64 `json:"id"` + Title string `json:"title"` + Key string `json:"key"` +} + +// material is a public key line without its comment: its type and its base64, which is what makes it the same key. +func material(line string) string { + f := strings.Fields(line) + if len(f) < 2 { + return strings.TrimSpace(line) + } + return f[0] + " " + f[1] +} + +// Keys lists the SSH keys the account holds. +func (m *Accounts) Keys(ctx context.Context, user string) ([]Key, error) { + var all []Key + for page := 1; page <= 20; page++ { + var got []Key + if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/users/%s/keys?limit=50&page=%d", user, page), &got); err != nil { + return nil, fmt.Errorf("the keys of %s could not be read: %w", user, err) + } + all = append(all, got...) + if len(got) < 50 { + break + } + } + return all, nil +} + +// AddKey registers a key for the account, one it may write with. +func (m *Accounts) AddKey(ctx context.Context, user string, k WantedKey) error { + err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users/"+user+"/keys", + map[string]any{"title": k.Title, "key": k.Key, "read_only": false}, nil) + if err != nil { + return fmt.Errorf("the key %s could not be added for %s: %w", k.Title, user, err) + } + return nil +} + +// DeleteKey removes one key of the account, by its id. +func (m *Accounts) DeleteKey(ctx context.Context, user string, id int64) error { + err := m.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/admin/users/%s/keys/%d", user, id), nil, nil) + if err != nil && !errors.Is(err, forgehttp.ErrNotFound) { + return fmt.Errorf("the key %d of %s could not be deleted: %w", id, user, err) + } + return nil +} + +// EnsureKeys registers each wanted key the account does not hold and removes the keys the mesh registered +// (title starting `mesh:`) that are no longer wanted. A key whose title does not start `mesh:` is the operator's and +// is never touched, and a wanted key the account already holds under another title is left as it is (ADR 0399 §2). +// A key is added before another title's is removed, so an account is not left without one. +func (m *Accounts) EnsureKeys(ctx context.Context, user string, wanted []WantedKey) error { + held, err := m.Keys(ctx, user) + if err != nil { + return err + } + have := map[string]bool{} + for _, k := range held { + have[material(k.Key)] = true + } + want := map[string]bool{} + for _, w := range wanted { + want[material(w.Key)] = true + } + gone := map[int64]bool{} + remove := func(k Key) error { + if gone[k.ID] { + return nil + } + gone[k.ID] = true + if err := m.DeleteKey(ctx, user, k.ID); err != nil { + return err + } + m.say("removed the key %s of %s: it is no longer wanted", k.Title, user) + return nil + } + for _, w := range wanted { + if have[material(w.Key)] { + continue + } + // The forge holds a title once: the mesh's key of the same title, now another key, goes first. + for _, k := range held { + if k.Title == w.Title && strings.HasPrefix(k.Title, TitlePrefix) && !want[material(k.Key)] { + if err := remove(k); err != nil { + return err + } + } + } + if err := m.AddKey(ctx, user, w); err != nil { + return err + } + m.say("registered the key %s for %s", w.Title, user) + } + for _, k := range held { + if strings.HasPrefix(k.Title, TitlePrefix) && !want[material(k.Key)] { + if err := remove(k); err != nil { + return err + } + } + } + return nil +} diff --git a/modules/gitea/internal/forgeaccount/keys_test.go b/modules/gitea/internal/forgeaccount/keys_test.go new file mode 100644 index 00000000..105c871a --- /dev/null +++ b/modules/gitea/internal/forgeaccount/keys_test.go @@ -0,0 +1,192 @@ +package forgeaccount + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + + "gitea/internal/forgehttp" +) + +type heldKey struct { + ID int64 `json:"id"` + Title string `json:"title"` + Key string `json:"key"` +} + +// keyForge is the part of gitea's API the keys use, as the administrator's login reaches it (issue 955 F2). +type keyForge struct { + mu sync.Mutex + keys map[string][]heldKey + next int64 + calls []string + posts []map[string]any +} + +var keysPath = regexp.MustCompile(`^/api/v1/(?:admin/)?users/([^/]+)/keys(?:/(\d+))?$`) + +func (f *keyForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, r.Method+" "+r.URL.Path) + m := keysPath.FindStringSubmatch(r.URL.Path) + if m == nil { + http.NotFound(w, r) + return + } + user := m[1] + reply := func(status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v != nil { + _ = json.NewEncoder(w).Encode(v) + } + } + switch { + case r.Method == http.MethodGet && m[2] == "" && !strings.Contains(r.URL.Path, "/admin/"): + reply(200, f.keys[user]) + case r.Method == http.MethodPost && m[2] == "" && strings.Contains(r.URL.Path, "/admin/"): + body, _ := io.ReadAll(r.Body) + var in map[string]any + _ = json.Unmarshal(body, &in) + f.posts = append(f.posts, in) + key, _ := in["key"].(string) + for _, k := range f.keys[user] { + if k.Key == key { + reply(422, map[string]any{"message": "Key content has been used as non-deploy key"}) + return + } + } + title, _ := in["title"].(string) + for _, k := range f.keys[user] { + if k.Title == title { + reply(422, map[string]any{"message": "Key title has been used"}) + return + } + } + f.next++ + f.keys[user] = append(f.keys[user], heldKey{ID: f.next, Title: title, Key: key}) + reply(201, map[string]any{"id": f.next}) + case r.Method == http.MethodDelete && m[2] != "" && strings.Contains(r.URL.Path, "/admin/"): + var kept []heldKey + for _, k := range f.keys[user] { + if fmt.Sprint(k.ID) != m[2] { + kept = append(kept, k) + } + } + f.keys[user] = kept + reply(204, nil) + default: + http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, 400) + } +} + +const ( + keyA = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + keyB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" +) + +func keyAccounts(t *testing.T, f *keyForge) *Accounts { + t.Helper() + srv := httptest.NewServer(f) + t.Cleanup(srv.Close) + pw := filepath.Join(t.TempDir(), "pw") + if err := os.WriteFile(pw, []byte("x\n"), 0o600); err != nil { + t.Fatal(err) + } + return &Accounts{Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Log: func(string, ...any) {}} +} + +func TestEnsureKeysAddsTheWantedKeyUnderItsTitle(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{}} + a := keyAccounts(t, f) + if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyA + " nox@g14 (mesh)"}}); err != nil { + t.Fatal(err) + } + if len(f.posts) != 1 || f.posts[0]["title"] != "mesh:g14.nox" || f.posts[0]["read_only"] != false || + f.posts[0]["key"] != keyA+" nox@g14 (mesh)" { + t.Fatalf("posts = %v", f.posts) + } +} + +func TestEnsureKeysAgainChangesNothing(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{}} + a := keyAccounts(t, f) + want := []WantedKey{{Title: "mesh:g14.nox", Key: keyA + " nox@g14 (mesh)"}} + for range 2 { + if err := a.EnsureKeys(t.Context(), "agent-g14", want); err != nil { + t.Fatal(err) + } + } + if len(f.posts) != 1 || len(f.keys["agent-g14"]) != 1 { + t.Fatalf("posts = %v, held = %v", f.posts, f.keys) + } +} + +func TestEnsureKeysReplacesAChangedMeshKeyOfTheSameTitle(t *testing.T) { + // The forge refuses a title held twice, so the old key goes first. + f := &keyForge{keys: map[string][]heldKey{"agent-g14": {{ID: 7, Title: "mesh:g14.nox", Key: keyA}}}, next: 7} + a := keyAccounts(t, f) + if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyB + " nox@g14 (mesh)"}}); err != nil { + t.Fatal(err) + } + held := f.keys["agent-g14"] + if len(held) != 1 || !strings.HasPrefix(held[0].Key, keyB) { + t.Fatalf("held = %v", held) + } +} + +func TestEnsureKeysRemovesAMeshKeyOfAnotherTitleAfterTheNewOneIsIn(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{"agent-g14": {{ID: 7, Title: "mesh:g14.old", Key: keyA}}}, next: 7} + a := keyAccounts(t, f) + if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyB}}); err != nil { + t.Fatal(err) + } + var post, del int + for i, c := range f.calls { + if strings.HasPrefix(c, "POST") { + post = i + } + if strings.HasPrefix(c, "DELETE") { + del = i + } + } + if post == 0 || del == 0 || post > del || len(f.keys["agent-g14"]) != 1 || f.keys["agent-g14"][0].Title != "mesh:g14.nox" { + t.Fatalf("calls = %v, held = %v", f.calls, f.keys) + } +} + +func TestEnsureKeysNeverTouchesAKeyTheOperatorAdded(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{"agent-g14": { + {ID: 3, Title: "my laptop", Key: keyB}, + {ID: 7, Title: "mesh:g14.nox", Key: keyA}, + }}, next: 7} + a := keyAccounts(t, f) + // Nothing wanted any more: the mesh's key goes, the operator's stays. + if err := a.EnsureKeys(t.Context(), "agent-g14", nil); err != nil { + t.Fatal(err) + } + held := f.keys["agent-g14"] + if len(held) != 1 || held[0].Title != "my laptop" { + t.Fatalf("held = %v", held) + } +} + +func TestEnsureKeysLeavesAWantedKeyTheOperatorAlreadyHoldsUnderAnotherTitle(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{"agent-g14": {{ID: 3, Title: "by hand", Key: keyA}}}, next: 3} + a := keyAccounts(t, f) + if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyA + " nox@g14 (mesh)"}}); err != nil { + t.Fatal(err) + } + if len(f.posts) != 0 || len(f.keys["agent-g14"]) != 1 || f.keys["agent-g14"][0].Title != "by hand" { + t.Fatalf("posts = %v, held = %v", f.posts, f.keys) + } +} -- 2.54.0 From b2009f1bb5637d9ed52fce881996b9703117eaf4 Mon Sep 17 00:00:00 2001 From: fix-955-ssh Date: Fri, 16 Oct 2026 22:43:18 +0200 Subject: [PATCH 03/12] Keep the machine's forge SSH key and its keys entry (issue 955 S1) --- .../cmd/ssh-client-tools/keykeeper.go | 115 ++++++++++++++ .../cmd/ssh-client-tools/keykeeper_test.go | 140 ++++++++++++++++++ .../ssh-client/cmd/ssh-client-tools/main.go | 23 +++ modules/ssh-client/go.mod | 2 +- modules/ssh-client/go.sum | 2 + 5 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 modules/ssh-client/cmd/ssh-client-tools/keykeeper.go create mode 100644 modules/ssh-client/cmd/ssh-client-tools/keykeeper_test.go 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_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/go.mod b/modules/ssh-client/go.mod index b086b90b..e8823355 100644 --- a/modules/ssh-client/go.mod +++ b/modules/ssh-client/go.mod @@ -2,4 +2,4 @@ module sshclient go 1.22 -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 diff --git a/modules/ssh-client/go.sum b/modules/ssh-client/go.sum index 0dd60617..405de7d4 100644 --- a/modules/ssh-client/go.sum +++ b/modules/ssh-client/go.sum @@ -1,2 +1,4 @@ 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= -- 2.54.0 From a4cdd05cb2e111ec69e43c152468b3748a8baf66 Mon Sep 17 00:00:00 2001 From: fix-955-ssh Date: Fri, 16 Oct 2026 22:43:29 +0200 Subject: [PATCH 04/12] Declare ssh-client's keys state and its forge-account requirement (issue 955 S2) --- .../cmd/ssh-client-tools/templates_test.go | 26 +++++++++++++++++++ modules/ssh-client/module.json | 6 +++++ 2 files changed, 32 insertions(+) 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..dd06a356 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/templates_test.go +++ b/modules/ssh-client/cmd/ssh-client-tools/templates_test.go @@ -62,3 +62,29 @@ 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) + } +} diff --git a/modules/ssh-client/module.json b/modules/ssh-client/module.json index 5bc3c2e2..126486a5 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": [ { -- 2.54.0 From ad5a0f8e44371c265952b7c8fda3aaabd9b99a48 Mon Sep 17 00:00:00 2001 From: fix-955-ssh Date: Fri, 16 Oct 2026 22:43:51 +0200 Subject: [PATCH 05/12] Place the forge's known host, SSH Host and git rewrite (issue 955 S3) --- .../cmd/ssh-client-tools/templates_test.go | 87 +++++++++++++++++++ modules/ssh-client/files/forge | 5 ++ modules/ssh-client/files/gitconfig | 2 + modules/ssh-client/files/known_hosts | 1 + modules/ssh-client/module.json | 33 +++++++ 5 files changed, 128 insertions(+) create mode 100644 modules/ssh-client/files/forge create mode 100644 modules/ssh-client/files/gitconfig create mode 100644 modules/ssh-client/files/known_hosts 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 dd06a356..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" ) @@ -88,3 +89,89 @@ func TestManifestKeepsKeysAndRequiresForgeAccount(t *testing.T) { 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/module.json b/modules/ssh-client/module.json index 126486a5..44cc6086 100644 --- a/modules/ssh-client/module.json +++ b/modules/ssh-client/module.json @@ -51,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": { -- 2.54.0 From 9cb615f121138baf750cf996944651ffc245eea6 Mon Sep 17 00:00:00 2001 From: fix-955-ssh Date: Fri, 16 Oct 2026 22:44:36 +0200 Subject: [PATCH 06/12] Prove the forge key process over a real bus in the merge check (issue 955 S4) --- .../ssh-client-tools/keykeeper_bus_test.go | 190 ++++++++++++++++++ modules/ssh-client/go.mod | 21 +- modules/ssh-client/go.sum | 27 ++- 3 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 modules/ssh-client/cmd/ssh-client-tools/keykeeper_bus_test.go 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/go.mod b/modules/ssh-client/go.mod index e8823355..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.14-0.20261013182924-5933eb757d44 +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 405de7d4..f84463ba 100644 --- a/modules/ssh-client/go.sum +++ b/modules/ssh-client/go.sum @@ -1,4 +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= -- 2.54.0 From c346bba34e5029b9f8d332ba3941947722d58640 Mon Sep 17 00:00:00 2001 From: "fix-955-forge@novox" Date: Fri, 16 Oct 2026 22:45:51 +0200 Subject: [PATCH 07/12] Add the forge-account provider: an agent account, its team and its machine key per consumer (issue 955 F3) --- modules/gitea/cmd/agent-token/token.go | 5 +- .../gitea/cmd/forge-account-provider/main.go | 126 ++++++ .../cmd/forge-account-provider/main_test.go | 22 + .../cmd/forge-account-provider/service.go | 383 ++++++++++++++++ .../forge-account-provider/service_test.go | 417 ++++++++++++++++++ modules/gitea/go.mod | 2 +- modules/gitea/go.sum | 4 +- .../gitea/internal/forgeaccount/account.go | 13 + modules/gitea/internal/forgeaccount/keys.go | 23 + .../gitea/internal/forgeaccount/user_test.go | 33 ++ 10 files changed, 1022 insertions(+), 6 deletions(-) create mode 100644 modules/gitea/cmd/forge-account-provider/main.go create mode 100644 modules/gitea/cmd/forge-account-provider/main_test.go create mode 100644 modules/gitea/cmd/forge-account-provider/service.go create mode 100644 modules/gitea/cmd/forge-account-provider/service_test.go create mode 100644 modules/gitea/internal/forgeaccount/user_test.go diff --git a/modules/gitea/cmd/agent-token/token.go b/modules/gitea/cmd/agent-token/token.go index e427b632..8b56fc37 100644 --- a/modules/gitea/cmd/agent-token/token.go +++ b/modules/gitea/cmd/agent-token/token.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "os" - "regexp" "sort" "strings" "sync" @@ -50,7 +49,7 @@ const ( TeamName = forgeaccount.TeamName ) -var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) +var nodeName = forgeaccount.NodeName // fromTheVault refuses every caller but the vault's own account (ADR 0349 clause 10): whoever else could call this // tool would hold every agent's credential. @@ -196,7 +195,7 @@ func (m *Minter) Answer(ask Ask) (Answer, error) { skip(h, "the forge names no origin it is reached at from that machine") continue } - account := AccountPrefix + h.Node + account := forgeaccount.ForgeUser(h.Node, "machine") if err := m.accounts().EnsureAccount(ctx, account); err != nil { return Answer{}, err } diff --git a/modules/gitea/cmd/forge-account-provider/main.go b/modules/gitea/cmd/forge-account-provider/main.go new file mode 100644 index 00000000..1f0148a8 --- /dev/null +++ b/modules/gitea/cmd/forge-account-provider/main.go @@ -0,0 +1,126 @@ +// forge-account-provider: gitea as the provider of `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955). A process +// the node's runtime launches and speaks MCP to over stdio, beside the module's other bundles. +// +// For each machine whose ssh-client binds the provision and has reported its public key (ssh-client's state `keys`), +// it makes the machine's agent a forge account of its own (agent-, in the agents team), registers the key for +// it, and serves where and as whom to reach the forge over ssh: the user, the forge machine's mesh name and port, its +// host key and the two url roots. A public key is no secret, so the provision has no credential. +// +// stdout is the MCP channel; it says what it says on stderr, and never the admin's password. +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/provider" + + "gitea/internal/forgehttp" +) + +func say(format string, args ...any) { + fmt.Fprintf(os.Stderr, "[forge-account] "+format+"\n", args...) +} + +// execRunner runs a program on this machine. +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).Output() + if err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + return out, nil +} + +// stateKeys is ssh-client's state `keys` over the runtime (the manifest's `reads` grants it). +type stateKeys struct{} + +func (stateKeys) Watch(handle func(key, op string, value []byte, current bool)) error { + return stdio.State("ssh-client.keys").Watch("", func(c stdio.StateChange) error { + handle(c.Key, c.Op, c.Value, c.Current) + return nil + }) +} + +// configFromEnv is the service's environment as the manifest hands it, naming what is missing. +func configFromEnv(getenv func(string) string) (Config, error) { + port, perr := strconv.Atoi(getenv("MESH_GITEA_SSH_PORT")) + cfg := Config{ + Node: getenv("MESH_NODE"), + HostFile: getenv("MESH_FORGE_HOST_FILE"), + OriginsFile: getenv("MESH_GITEA_ORIGINS_FILE"), + Port: port, + Forge: forgehttp.Client{URL: strings.TrimRight(getenv("MESH_GITEA_URL"), "/"), User: getenv("MESH_GITEA_ADMIN_USER"), + PasswordFile: getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), Timeout: 20 * time.Second}, + Runner: execRunner{}, + } + var missing []string + for _, kv := range [][2]string{{"MESH_NODE", cfg.Node}, {"MESH_FORGE_HOST_FILE", cfg.HostFile}, + {"MESH_GITEA_ORIGINS_FILE", cfg.OriginsFile}, {"MESH_GITEA_URL", cfg.Forge.URL}, {"MESH_GITEA_ADMIN_USER", cfg.Forge.User}, + {"MESH_GITEA_ADMIN_PASSWORD_FILE", cfg.Forge.PasswordFile}} { + if kv[1] == "" { + missing = append(missing, kv[0]) + } + } + if perr != nil || port <= 0 { + missing = append(missing, "MESH_GITEA_SSH_PORT") + } + if len(missing) > 0 { + return Config{}, fmt.Errorf("the forge-account provider cannot start: %s not set by the manifest", strings.Join(missing, ", ")) + } + return cfg, nil +} + +func main() { + cfg, err := configFromEnv(os.Getenv) + if err != nil { + // Said, not fatal: the runtime would restart a process that cannot do better. + say("%v; providing nothing", err) + if err := stdio.Serve("", nil); err != nil { + say("%v", err) + os.Exit(1) + } + return + } + cfg.Log = say + svc := NewService(cfg) + p := svc.Provider(provider.StateBus(Module)) + ctx := context.Background() + // A pass answers every consumer; one at a time, and one per change of a machine's key. + var passing sync.Mutex + pass := func() { + passing.Lock() + defer passing.Unlock() + if _, err := p.Pass(ctx); err != nil { + say("a pass failed: %v", err) + } + } + svc.OnKeys = func(string) { go pass() } + go func() { + if err := svc.WatchKeys(ctx, stateKeys{}); err != nil { + say("the machines' keys are not watched: %v", err) + return + } + say("watching the machines' keys") + pass() + }() + go func() { + if err := p.Run(ctx); err != nil { + say("the provider stopped: %v", err) + } + }() + if err := stdio.Serve("", nil); err != nil { + say("%v", err) + os.Exit(1) + } +} diff --git a/modules/gitea/cmd/forge-account-provider/main_test.go b/modules/gitea/cmd/forge-account-provider/main_test.go new file mode 100644 index 00000000..d8e04990 --- /dev/null +++ b/modules/gitea/cmd/forge-account-provider/main_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "strings" + "testing" +) + +func TestConfigFromEnvNamesWhatIsMissing(t *testing.T) { + env := map[string]string{"MESH_NODE": "novox", "MESH_FORGE_HOST_FILE": "/h", "MESH_GITEA_ORIGINS_FILE": "/o", + "MESH_GITEA_URL": "http://127.0.0.1:3000/", "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "/p", "MESH_GITEA_SSH_PORT": "222"} + cfg, err := configFromEnv(func(k string) string { return env[k] }) + if err != nil || cfg.Port != 222 || cfg.Forge.URL != "http://127.0.0.1:3000" { + t.Fatalf("%+v %v", cfg, err) + } + delete(env, "MESH_FORGE_HOST_FILE") + env["MESH_GITEA_SSH_PORT"] = "x" + _, err = configFromEnv(func(k string) string { return env[k] }) + if err == nil || !strings.Contains(err.Error(), "MESH_FORGE_HOST_FILE") || !strings.Contains(err.Error(), "MESH_GITEA_SSH_PORT") { + t.Fatalf("%v", err) + } +} diff --git a/modules/gitea/cmd/forge-account-provider/service.go b/modules/gitea/cmd/forge-account-provider/service.go new file mode 100644 index 00000000..b9975ea6 --- /dev/null +++ b/modules/gitea/cmd/forge-account-provider/service.go @@ -0,0 +1,383 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" + + "gitea/internal/forgeaccount" + "gitea/internal/forgehttp" +) + +// ProvisionName is what gitea provides here, as its manifest says (novox/hq ADR 0400). +const ProvisionName = "forge-account" + +// Module is gitea's name in the mesh. +const Module = "gitea" + +// Runner runs a program; a test fakes it. +type Runner interface { + Run(ctx context.Context, name string, args ...string) ([]byte, error) +} + +// KeySource is the machines' public keys as the bus keeps them (ssh-client's state `keys`): the whole current set, +// then each change. handle is told the key, the operation ("put" or "delete"), the value and whether it was there +// when the watch began. +type KeySource interface { + Watch(handle func(key, op string, value []byte, current bool)) error +} + +// Config is what the service needs from its environment. +type Config struct { + // Node is the forge's machine. + Node string + // HostFile holds the forge machine's own mesh name (a fact of the machine), OriginsFile the forge's public + // origins, one per line. + HostFile, OriginsFile string + // Port is where the forge's sshd is published on the mesh. + Port int + Forge forgehttp.Client + Runner Runner + Log func(format string, a ...any) +} + +// Service answers the consumers of the provision `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955): for each +// machine whose ssh-client reported a public key it makes the machine's agent a forge account, registers the key +// for it, and serves where and as whom to reach the forge over ssh. A key is no secret: the offer has no credential. +type Service struct { + cfg Config + accounts *forgeaccount.Accounts + + // OnKeys is told the node whose key changed, so its consumer is answered without waiting for the next pass. + OnKeys func(node string) + // Pause waits between asks of a watch the runtime does not answer yet. + Pause func(time.Duration) + + mu sync.Mutex + keys map[string]machineKey + known string // the forge's host key line, as the machines write it into known_hosts + + making sync.Mutex // one Make at a time: they register keys, and two at once would race each other +} + +type machineKey struct { + Node, Account, Kind, Public string +} + +// NewService is the service over its environment. +func NewService(cfg Config) *Service { + if cfg.Log == nil { + cfg.Log = func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[gitea forge-account] "+f+"\n", a...) } + } + return &Service{cfg: cfg, keys: map[string]machineKey{}, Pause: time.Sleep, + accounts: &forgeaccount.Accounts{Forge: cfg.Forge, Log: cfg.Log}} +} + +// Provider is the provider helper answering the consumers over bus. Every consumer is made again every ten minutes, +// which puts back an account, its team or a key changed by hand. +func (s *Service) Provider(bus provider.Bus) *provider.Provider { + return provider.New(provider.Options{ + Node: s.cfg.Node, Module: Module, + Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.NoCredential}}, + Bus: &valuesBus{Bus: bus, s: s, written: map[string][]byte{}}, + Make: s.Make, Gone: s.Gone, + Every: 10 * time.Minute, + Logf: s.cfg.Log, + }) +} + +// errNoKey is a consumer whose machine has reported no key yet: nothing is made, and the client says it waits. +type errNoKey struct{ node string } + +func (e errNoKey) Error() string { + return "no key from " + e.node + " yet: its ssh-client has not reported a machine key, so there is no account " + + "and no entry to make" +} + +// Make brings one consumer's account, its team membership and its keys into being, as the machine reported them. It +// leaves what is as asked alone, so it is asked again freely. +func (s *Service) Make(ctx context.Context, c provider.Consumer) error { + s.making.Lock() + defer s.making.Unlock() + node := c.Entry.Consumer + user := forgeaccount.ForgeUser(node, "machine") + if user == "" { + return fmt.Errorf("%q is not a machine's name an account can be named for", node) + } + have := s.keysOf(node) + if len(have) == 0 { + return errNoKey{node} + } + if _, err := s.hostName(); err != nil { + return err + } + known, err := s.scan(ctx) + if err != nil { + return err + } + if err := s.accounts.EnsureAccount(ctx, user); err != nil { + return err + } + if err := s.accounts.EnsureInTeam(ctx, user); err != nil { + return err + } + var wanted []forgeaccount.WantedKey + for _, k := range have { + wanted = append(wanted, forgeaccount.WantedKey{Title: forgeaccount.KeyTitle(k.Node, k.Account), Key: k.Public}) + } + if err := s.accounts.EnsureKeys(ctx, user, wanted); err != nil { + return err + } + s.mu.Lock() + s.known = known + s.mu.Unlock() + return nil +} + +// Gone removes the keys the mesh registered for a machine that no longer binds the forge. The account stays: it may +// hold what the machine's agent wrote, and the keys of the operator's own that were added by hand are not touched +// (ADR 0399 §2). +func (s *Service) Gone(ctx context.Context, e provider.Entry) error { + s.making.Lock() + defer s.making.Unlock() + user := forgeaccount.ForgeUser(e.Consumer, "machine") + if user == "" { + return nil + } + return s.accounts.RemoveKeysTitled(ctx, user, forgeaccount.TitlePrefix+e.Consumer+".") +} + +// KeyChanged takes in one machine key as the bus keeps it, and tells OnKeys its node. +func (s *Service) KeyChanged(key string, value []byte) { + if node := s.store(key, value); node != "" && s.OnKeys != nil { + s.OnKeys(node) + } +} + +// KeyGone forgets one machine key, and tells OnKeys its node. +func (s *Service) KeyGone(key string) { + node, _, _ := strings.Cut(key, ".") + s.mu.Lock() + delete(s.keys, key) + s.mu.Unlock() + if s.OnKeys != nil { + s.OnKeys(node) + } +} + +// store keeps a machine key that reads as one: its node and account the key's own, and a public half that is a +// public key line and nothing private. It answers the node, or "" for a value it did not keep (and drops what was +// kept under that key, since the key now says something else). +func (s *Service) store(key string, value []byte) string { + node, account, _ := strings.Cut(key, ".") + var k machineKey + var raw struct { + Node, Account, Kind, Public string + } + err := json.Unmarshal(value, &raw) + k = machineKey(raw) + s.mu.Lock() + defer s.mu.Unlock() + if err != nil || k.Node != node || k.Account != account || !publicKeyLine(k.Public) { + if _, had := s.keys[key]; had { + delete(s.keys, key) + } + s.cfg.Log("the key %s is not a machine's public key; it is not registered", key) + return "" + } + s.keys[key] = k + return node +} + +func publicKeyLine(line string) bool { + f := strings.Fields(line) + return len(f) >= 2 && f[0] == "ssh-ed25519" && !strings.Contains(line, "PRIVATE") && !strings.ContainsAny(line, "\r\n") +} + +// keysOf are the machine keys one node reported, by key. +func (s *Service) keysOf(node string) []machineKey { + s.mu.Lock() + defer s.mu.Unlock() + var names []string + for key, k := range s.keys { + if k.Node == node && k.Kind == "machine" && strings.HasPrefix(key, node+".") { + names = append(names, key) + } + } + sort.Strings(names) + out := make([]machineKey, 0, len(names)) + for _, n := range names { + out = append(out, s.keys[n]) + } + return out +} + +// WatchKeys keeps the machines' keys as the bus holds them, asking again until the runtime answers (the channel to it +// opens as the bundle starts, and the state of ssh-client may arrive after this one). A change after the first +// values is told to OnKeys; the first values are not, since one pass answers them all. +func (s *Service) WatchKeys(ctx context.Context, src KeySource) error { + waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second} + for attempt := 0; ; attempt++ { + err := src.Watch(func(key, op string, value []byte, current bool) { + switch { + case op != "put" && current: + case op != "put": + s.KeyGone(key) + case current: + s.store(key, value) + default: + s.KeyChanged(key, value) + } + }) + if err == nil { + return nil + } + pause := time.Minute + if attempt < len(waits) { + pause = waits[attempt] + } + s.cfg.Log("the machines' keys cannot be watched yet (%v); asking again in %s", err, pause) + if ctx.Err() != nil { + return ctx.Err() + } + s.Pause(pause) + } +} + +// hostName is the forge machine's own mesh name, from the fact the mesh writes. +func (s *Service) hostName() (string, error) { + raw, err := os.ReadFile(s.cfg.HostFile) + if err != nil { + return "", fmt.Errorf("the forge machine's mesh name cannot be read (%s): %w", s.cfg.HostFile, err) + } + host := strings.TrimSpace(string(raw)) + if host == "" || strings.ContainsAny(host, " \t\n[]:/") { + return "", fmt.Errorf("%s holds %q, which is not a machine's mesh name", s.cfg.HostFile, host) + } + return host, nil +} + +// httpsBase is the forge's public https root with a trailing slash. +func (s *Service) httpsBase() (string, error) { + raw, err := os.ReadFile(s.cfg.OriginsFile) + if err != nil { + return "", fmt.Errorf("the forge's origins cannot be read (%s): %w", s.cfg.OriginsFile, err) + } + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimRight(strings.TrimSpace(line), "/") + if strings.HasPrefix(line, "https://") { + return line + "/", nil + } + } + return "", fmt.Errorf("%s names no https origin", s.cfg.OriginsFile) +} + +// scan reads the forge's ed25519 host key from its own sshd and writes it as the machines' known_hosts line. +func (s *Service) scan(ctx context.Context) (string, error) { + host, err := s.hostName() + if err != nil { + return "", err + } + out, err := s.cfg.Runner.Run(ctx, "ssh-keyscan", "-t", "ed25519", "-p", fmt.Sprint(s.cfg.Port), "127.0.0.1") + if err != nil { + return "", fmt.Errorf("the forge's ssh host key cannot be read: %w", err) + } + return knownHostLine(string(out), host, s.cfg.Port) +} + +// knownHostLine is the ed25519 line of ssh-keyscan's output with its host written as the mesh name and port. +func knownHostLine(scanned, host string, port int) (string, error) { + for _, line := range strings.Split(scanned, "\n") { + f := strings.Fields(line) + if len(f) >= 3 && !strings.HasPrefix(f[0], "#") && f[1] == "ssh-ed25519" { + return fmt.Sprintf("[%s]:%d %s %s", host, port, f[1], f[2]), nil + } + } + return "", errors.New("the forge's sshd gave no ed25519 host key") +} + +// valuesFor are the values served to one machine's consumer (ADR 0400 §3, §4). +func (s *Service) valuesFor(node string) (map[string]any, error) { + user := forgeaccount.ForgeUser(node, "machine") + host, err := s.hostName() + if err != nil { + return nil, err + } + https, err := s.httpsBase() + if err != nil { + return nil, err + } + s.mu.Lock() + known := s.known + s.mu.Unlock() + if user == "" || known == "" { + return nil, fmt.Errorf("nothing is known to serve %s yet", node) + } + return map[string]any{"user": user, "ssh_host": host, "ssh_port": s.cfg.Port, "known_host": known, + "ssh_base": fmt.Sprintf("ssh://git@%s:%d/", host, s.cfg.Port), "https_base": https}, nil +} + +// valuesBus writes each consumer's entry with the values that differ per consumer or come from the machine's own +// facts. The provider helper's offer states what is the same for all; the helper has no place for the rest. +type valuesBus struct { + provider.Bus + s *Service + + mu sync.Mutex + written map[string][]byte +} + +func (b *valuesBus) Put(ctx context.Context, bucket, key string, value []byte) error { + if bucket != provider.BucketOf(Module) { + return b.Bus.Put(ctx, bucket, key, value) + } + var e provider.Entry + if err := json.Unmarshal(value, &e); err != nil { + return err + } + if e.Refused == "" { + extra, err := b.s.valuesFor(e.Consumer) + if err != nil { + return err + } + if e.Values == nil { + e.Values = map[string]any{} + } + for k, v := range extra { + e.Values[k] = v + } + } + out, err := json.Marshal(e) + if err != nil { + return err + } + b.mu.Lock() + same := bytes.Equal(b.written[key], out) + b.mu.Unlock() + if same { + return nil // the helper writes what it composed on every pass; this one stands + } + if err := b.Bus.Put(ctx, bucket, key, out); err != nil { + return err + } + b.mu.Lock() + b.written[key] = out + b.mu.Unlock() + return nil +} + +func (b *valuesBus) Delete(ctx context.Context, bucket, key string) error { + b.mu.Lock() + delete(b.written, key) + b.mu.Unlock() + return b.Bus.Delete(ctx, bucket, key) +} diff --git a/modules/gitea/cmd/forge-account-provider/service_test.go b/modules/gitea/cmd/forge-account-provider/service_test.go new file mode 100644 index 00000000..fdfdf949 --- /dev/null +++ b/modules/gitea/cmd/forge-account-provider/service_test.go @@ -0,0 +1,417 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" + + "gitea/internal/forgehttp" +) + +const ( + pubA = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + pubB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" +) + +// fakeBus is the bus as the provider reads and writes it. +type fakeBus struct { + mu sync.Mutex + buckets map[string]map[string][]byte +} + +func newBus() *fakeBus { return &fakeBus{buckets: map[string]map[string][]byte{}} } + +func (b *fakeBus) set(bucket, key string, v []byte) { + b.mu.Lock() + defer b.mu.Unlock() + if b.buckets[bucket] == nil { + b.buckets[bucket] = map[string][]byte{} + } + b.buckets[bucket][key] = v +} +func (b *fakeBus) Watch(_ context.Context, bucket string, h func(string, []byte)) error { + b.mu.Lock() + cur := map[string][]byte{} + for k, v := range b.buckets[bucket] { + cur[k] = v + } + b.mu.Unlock() + for k, v := range cur { + h(k, v) + } + return nil +} +func (b *fakeBus) Put(_ context.Context, bucket, key string, v []byte) error { + b.set(bucket, key, v) + return nil +} +func (b *fakeBus) Delete(_ context.Context, bucket, key string) error { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.buckets[bucket], key) + return nil +} +func (b *fakeBus) entry(t *testing.T, key string) provider.Entry { + t.Helper() + b.mu.Lock() + raw := b.buckets[provider.BucketOf("gitea")][key] + b.mu.Unlock() + if raw == nil { + t.Fatalf("no entry %s", key) + } + var e provider.Entry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + return e +} +func (b *fakeBus) has(key string) bool { + b.mu.Lock() + defer b.mu.Unlock() + _, ok := b.buckets[provider.BucketOf("gitea")][key] + return ok +} + +// bindClient makes ssh-client on node bind gitea's forge-account in the assignments mirror. +func (b *fakeBus) bindClient(node string) { + b.set(provider.AssignmentsBucket, node+".ssh-client", []byte(fmt.Sprintf( + `{"node":%q,"module":"ssh-client","provisioning":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":"mesh_%s_ssh_client"}]}}`, + node, node))) +} + +// fakeForge is gitea's API as far as the provider uses it. +type fakeForge struct { + mu sync.Mutex + users map[string]bool + teams map[string]bool + members []string + held map[string][]map[string]any + next int64 +} + +func newForge() *fakeForge { + return &fakeForge{users: map[string]bool{}, held: map[string][]map[string]any{}} +} + +var keyRe = regexp.MustCompile(`^/api/v1/(?:admin/)?users/([^/]+)/keys(?:/(\d+))?$`) + +func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + body, _ := io.ReadAll(r.Body) + var in map[string]any + _ = json.Unmarshal(body, &in) + reply := func(code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if v != nil { + _ = json.NewEncoder(w).Encode(v) + } + } + p := r.URL.Path + if m := keyRe.FindStringSubmatch(p); m != nil { + switch r.Method { + case http.MethodGet: + reply(200, f.held[m[1]]) + case http.MethodPost: + f.next++ + f.held[m[1]] = append(f.held[m[1]], map[string]any{"id": f.next, "title": in["title"], "key": in["key"]}) + reply(201, map[string]any{"id": f.next}) + case http.MethodDelete: + var kept []map[string]any + for _, k := range f.held[m[1]] { + if fmt.Sprint(k["id"]) != m[2] { + kept = append(kept, k) + } + } + f.held[m[1]] = kept + reply(204, nil) + } + return + } + switch { + case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/users/"): + if !f.users[strings.TrimPrefix(p, "/api/v1/users/")] { + reply(404, map[string]any{"message": "no"}) + return + } + reply(200, map[string]any{"active": true}) + case r.Method == http.MethodPost && p == "/api/v1/admin/users": + f.users[in["username"].(string)] = true + reply(201, map[string]any{}) + case p == "/api/v1/orgs/novox/teams" && r.Method == http.MethodGet: + reply(200, []map[string]any{{"id": 5, "name": "agents"}}) + case p == "/api/v1/teams/5" && r.Method == http.MethodPatch: + reply(200, map[string]any{}) + case strings.HasPrefix(p, "/api/v1/teams/5/members/") && r.Method == http.MethodPut: + f.members = append(f.members, strings.TrimPrefix(p, "/api/v1/teams/5/members/")) + reply(204, nil) + default: + http.Error(w, "unexpected "+r.Method+" "+p, 400) + } +} + +func (f *fakeForge) titles(user string) []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, k := range f.held[user] { + out = append(out, k["title"].(string)) + } + return out +} + +type fakeRunner struct { + out string + err error + args []string +} + +func (r *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + r.args = append([]string{name}, args...) + return []byte(r.out), r.err +} + +const scanned = "# 127.0.0.1:222 SSH-2.0-Go\n[127.0.0.1]:222 ssh-ed25519 AAAAC3HOSTKEY\n" + +type rig struct { + t *testing.T + bus *fakeBus + forge *fakeForge + svc *Service + runner *fakeRunner +} + +func newRig(t *testing.T) *rig { + t.Helper() + forge := newForge() + srv := httptest.NewServer(forge) + t.Cleanup(srv.Close) + dir := t.TempDir() + pw := filepath.Join(dir, "pw") + host := filepath.Join(dir, "host") + origins := filepath.Join(dir, "origins") + for p, c := range map[string]string{pw: "x\n", host: "novox.internal\n", origins: "https://git.novox.be\n"} { + if err := os.WriteFile(p, []byte(c), 0o600); err != nil { + t.Fatal(err) + } + } + runner := &fakeRunner{out: scanned} + svc := NewService(Config{Node: "novox", HostFile: host, OriginsFile: origins, Port: 222, + Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Runner: runner, + Log: func(string, ...any) {}}) + return &rig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner} +} + +func (r *rig) pass() []provider.Outcome { + r.t.Helper() + p := r.svc.Provider(r.bus) + out, err := p.Pass(context.Background()) + if err != nil { + r.t.Fatal(err) + } + return out +} + +func (r *rig) key(node, account, public string) { + b, _ := json.Marshal(map[string]string{"node": node, "account": account, "kind": "machine", "public": public, + "fingerprint": "SHA256:x", "at": "2026-10-16T10:00:00Z"}) + r.svc.KeyChanged(node+"."+account, b) +} + +func TestAConsumerWithAKeyGetsItsEntryAndItsKeyIsRegistered(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + r.key("g14", "nox", pubA+" nox@g14 (mesh)") + r.pass() + e := r.bus.entry(t, "g14.ssh-client.forge-account") + want := map[string]any{"user": "agent-g14", "ssh_host": "novox.internal", "ssh_port": float64(222), + "known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY", "ssh_base": "ssh://git@novox.internal:222/", + "https_base": "https://git.novox.be/"} + for k, v := range want { + if e.Values[k] != v { + t.Errorf("%s = %v, want %v", k, e.Values[k], v) + } + } + if e.VaultName != "" || e.User != "" { + t.Errorf("a public key is no secret; the entry names a credential: %+v", e) + } + if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "mesh:g14.nox" { + t.Errorf("registered keys = %v", got) + } + if !r.forge.users["agent-g14"] || len(r.forge.members) != 1 || r.forge.members[0] != "agent-g14" { + t.Errorf("account %v, team members %v", r.forge.users, r.forge.members) + } + if strings.Join(r.runner.args, " ") != "ssh-keyscan -t ed25519 -p 222 127.0.0.1" { + t.Errorf("scan = %v", r.runner.args) + } +} + +func TestAConsumerWithoutAKeyGetsNoEntryAndNoAccount(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + r.key("ace", "nox", pubB) // another machine's key is not g14's + out := r.pass() + if r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("an entry was written for a machine that reported no key") + } + if len(r.forge.users) != 0 { + t.Fatalf("an account was made: %v", r.forge.users) + } + if len(out) != 1 || !strings.Contains(out[0].Why, "no key") { + t.Fatalf("the wait should be said: %+v", out) + } +} + +func TestAKeyOfAnotherKindIsNotTheMachinesKey(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + b, _ := json.Marshal(map[string]string{"node": "g14", "account": "x", "kind": "session", "public": pubA}) + r.svc.KeyChanged("g14.x", b) + r.pass() + if r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("answered with a key that is not a machine's") + } +} + +func TestAChangedKeyReplacesTheOldMeshKey(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + r.key("g14", "nox", pubA+" nox@g14 (mesh)") + r.pass() + r.forge.mu.Lock() + r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB}) + r.forge.mu.Unlock() + r.key("g14", "nox", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC nox@g14 (mesh)") + r.pass() + got := strings.Join(r.forge.titles("agent-g14"), ",") + if got != "my own,mesh:g14.nox" { + t.Fatalf("keys = %s", got) + } + for _, k := range r.forge.held["agent-g14"] { + if k["title"] == "mesh:g14.nox" && !strings.Contains(k["key"].(string), "CCCC") { + t.Fatalf("the old key stayed: %v", k) + } + } +} + +func TestAConsumerThatNoLongerBindsLosesItsMeshKeysAndKeepsItsAccount(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + r.key("g14", "nox", pubA+" nox@g14 (mesh)") + r.pass() + r.forge.mu.Lock() + r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB}) + r.forge.mu.Unlock() + r.bus.mu.Lock() + delete(r.bus.buckets[provider.AssignmentsBucket], "g14.ssh-client") + r.bus.mu.Unlock() + r.pass() + if r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("the entry stayed") + } + if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "my own" { + t.Fatalf("keys = %v", got) + } + if !r.forge.users["agent-g14"] { + t.Fatal("the account was removed") + } +} + +func TestAKeyChangeNamesTheConsumersToAnswerAgain(t *testing.T) { + r := newRig(t) + var got []string + r.svc.OnKeys = func(node string) { got = append(got, node) } + r.key("g14", "nox", pubA) + if len(got) != 1 || got[0] != "g14" { + t.Fatalf("answered for %v", got) + } +} + +func TestAKeyThatIsNotReadableIsNotKept(t *testing.T) { + r := newRig(t) + r.svc.KeyChanged("g14.nox", []byte(`{"node":"g14","kind":"machine"`)) + r.svc.KeyChanged("g14.nox", []byte(`{"node":"g14","account":"nox","kind":"machine","public":"PRIVATE KEY"}`)) + r.bus.bindClient("g14") + r.pass() + if r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("a value that is no public key was registered") + } +} + +func TestTheForgesHostKeyUnreadableMeansNoEntry(t *testing.T) { + r := newRig(t) + r.runner.err = errors.New("connection refused") + r.bus.bindClient("g14") + r.key("g14", "nox", pubA) + r.pass() + if r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("an entry without the forge's host key") + } +} + +func TestKnownHostLineIsRewrittenToTheMeshName(t *testing.T) { + line, err := knownHostLine(scanned, "novox.internal", 222) + if err != nil || line != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" { + t.Fatalf("%q %v", line, err) + } + if _, err := knownHostLine("# nothing\n", "novox.internal", 222); err == nil { + t.Fatal("no key line was accepted") + } +} + +func TestKeysWatchedRetriesUntilItAnswersAndPassesOnChanges(t *testing.T) { + r := newRig(t) + src := &fakeKeys{failFirst: 2} + r.svc.Pause = func(time.Duration) {} + passes := 0 + r.svc.OnKeys = func(string) { passes++ } + if err := r.svc.WatchKeys(context.Background(), src); err != nil { + t.Fatal(err) + } + if src.calls != 3 { + t.Fatalf("asked %d times", src.calls) + } + src.send("g14.nox", "put", []byte(`{"node":"g14","account":"nox","kind":"machine","public":"`+pubA+`"}`), false) + if passes != 1 { + t.Fatalf("passes = %d", passes) + } + // What was there when the watch began is taken in, and one pass answers it, not one per key. + src.send("ace.nox", "put", []byte(`{"node":"ace","account":"nox","kind":"machine","public":"`+pubB+`"}`), true) + if passes != 1 { + t.Fatalf("a current value passed on its own: %d", passes) + } + src.send("g14.nox", "delete", nil, false) + if passes != 2 || len(r.svc.keysOf("g14")) != 0 { + t.Fatalf("passes = %d, keys = %v", passes, r.svc.keysOf("g14")) + } +} + +type fakeKeys struct { + failFirst int + calls int + handle func(key, op string, value []byte, current bool) +} + +func (f *fakeKeys) Watch(handle func(key, op string, value []byte, current bool)) error { + f.calls++ + if f.calls <= f.failFirst { + return errors.New("not yet") + } + f.handle = handle + return nil +} +func (f *fakeKeys) send(key, op string, v []byte, current bool) { f.handle(key, op, v, current) } diff --git a/modules/gitea/go.mod b/modules/gitea/go.mod index ea8089d6..0fa6eee9 100644 --- a/modules/gitea/go.mod +++ b/modules/gitea/go.mod @@ -2,7 +2,7 @@ module gitea go 1.25.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 require ( golang.org/x/crypto v0.50.0 diff --git a/modules/gitea/go.sum b/modules/gitea/go.sum index afe914a8..735d7c6c 100644 --- a/modules/gitea/go.sum +++ b/modules/gitea/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= 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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= diff --git a/modules/gitea/internal/forgeaccount/account.go b/modules/gitea/internal/forgeaccount/account.go index 57ec5776..6c36381b 100644 --- a/modules/gitea/internal/forgeaccount/account.go +++ b/modules/gitea/internal/forgeaccount/account.go @@ -12,6 +12,7 @@ import ( "fmt" "net/http" "os" + "regexp" "gitea/internal/forgehttp" ) @@ -143,3 +144,15 @@ func (m *Accounts) say(format string, a ...any) { } fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// NodeName is what a machine's name is, for it to name an account. +var NodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) + +// ForgeUser is the forge user of a machine's key of the given kind (ADR 0400 §3): `agent-` for the machine's +// own account (kind machine). A kind not built yet has none, and the answer is empty. +func ForgeUser(node, kind string) string { + if kind == "machine" && NodeName.MatchString(node) { + return AccountPrefix + node + } + return "" +} diff --git a/modules/gitea/internal/forgeaccount/keys.go b/modules/gitea/internal/forgeaccount/keys.go index 018241b9..2f5a43f6 100644 --- a/modules/gitea/internal/forgeaccount/keys.go +++ b/modules/gitea/internal/forgeaccount/keys.go @@ -130,3 +130,26 @@ func (m *Accounts) EnsureKeys(ctx context.Context, user string, wanted []WantedK } return nil } + +// RemoveKeysTitled removes the account's keys whose title starts with prefix, and only a prefix the mesh gave +// (starting `mesh:`, such as `mesh:g14.`): what the mesh registered for one machine, never a key of the operator's +// (ADR 0399 §2). +func (m *Accounts) RemoveKeysTitled(ctx context.Context, user, prefix string) error { + if !strings.HasPrefix(prefix, TitlePrefix) { + return nil + } + held, err := m.Keys(ctx, user) + if err != nil { + return err + } + for _, k := range held { + if !strings.HasPrefix(k.Title, prefix) { + continue + } + if err := m.DeleteKey(ctx, user, k.ID); err != nil { + return err + } + m.say("removed the key %s of %s: its machine no longer binds the forge", k.Title, user) + } + return nil +} diff --git a/modules/gitea/internal/forgeaccount/user_test.go b/modules/gitea/internal/forgeaccount/user_test.go new file mode 100644 index 00000000..82875058 --- /dev/null +++ b/modules/gitea/internal/forgeaccount/user_test.go @@ -0,0 +1,33 @@ +package forgeaccount + +import "testing" + +func TestForgeUserOfAMachineAccountIsTheNodesAgent(t *testing.T) { + if got := ForgeUser("g14", "machine"); got != "agent-g14" { + t.Fatalf("ForgeUser = %q", got) + } + if got := ForgeUser("g14", "other"); got != "" { + t.Fatalf("a kind not built yet has no forge user, got %q", got) + } +} + +func TestRemoveKeysTitledTakesOnlyTheMeshKeysOfThatTitle(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{"agent-g14": { + {ID: 1, Title: "mesh:g14.nox", Key: keyA}, + {ID: 2, Title: "mesh:g14.other", Key: keyB}, + {ID: 3, Title: "by hand", Key: "ssh-ed25519 CCCC"}, + }}, next: 3} + a := keyAccounts(t, f) + if err := a.RemoveKeysTitled(t.Context(), "agent-g14", "mesh:g14.nox"); err != nil { + t.Fatal(err) + } + if got := f.keys["agent-g14"]; len(got) != 2 || got[0].ID != 2 || got[1].ID != 3 { + t.Fatalf("held = %v", got) + } + if err := a.RemoveKeysTitled(t.Context(), "agent-g14", "by hand"); err != nil { + t.Fatal(err) + } + if len(f.keys["agent-g14"]) != 2 { + t.Fatal("a key that is not the mesh's was removed") + } +} -- 2.54.0 From 1081a5f3ea2c37417777cf94163ecec0ee262cca Mon Sep 17 00:00:00 2001 From: "fix-955-forge@novox" Date: Fri, 16 Oct 2026 22:46:27 +0200 Subject: [PATCH 08/12] Declare the forge-account provision, its provider bundle, its fact and the keys it reads (issue 955 F4) --- modules/gitea/README.md | 12 +++++++++ modules/gitea/files/consumers.json | 1 + modules/gitea/module.json | 42 ++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 modules/gitea/files/consumers.json diff --git a/modules/gitea/README.md b/modules/gitea/README.md index cb97c96f..fb7b319d 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -221,3 +221,15 @@ whenever the tools' account is set up, with the administrator's login (`GiteaAdm account is no repository's administrator, so no tool can. A rule found keeps the statuses it requires; a repository with none is given one that requires none. `judgeProtectionChange` refuses, for `main`, a change that enables push, drops the merge whitelist or widens it past that account. + +## The forge account for git over ssh (novox/hq ADR 0399, ADR 0400, issue 955) + +Gitea provides `forge-account`, and `cmd/forge-account-provider` answers its consumers (the `ssh-client` of each +machine). The provision has no credential: a public key is no secret. For a machine whose ssh-client has reported its +key in the state `keys` (read, `ssh-client.keys`), the provider makes the account `agent-` in the agents team +(`internal/forgeaccount`, the code `cmd/agent-token` shares), registers the key under the title `mesh:.`, +and serves the entry values `user`, `ssh_host` (the forge machine's mesh name, from the fact `forge-host`), `ssh_port`, +`known_host` (read from the forge's own sshd with `ssh-keyscan`), `ssh_base` and `https_base`. A machine that has +reported no key gets no entry, and its client says it waits. Keys titled `mesh:` that are no longer wanted are removed, +a key added by hand never is. Every consumer is made again every ten minutes and at once when a key changes. +`go test ./cmd/forge-account-provider ./internal/forgeaccount` runs it against a fake forge. diff --git a/modules/gitea/files/consumers.json b/modules/gitea/files/consumers.json new file mode 100644 index 00000000..790b51f9 --- /dev/null +++ b/modules/gitea/files/consumers.json @@ -0,0 +1 @@ +${consumers:forge-account} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index a086f1d3..6277d97e 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -177,6 +177,12 @@ "default": "mesh-tools mesh-admin jschoubben" } }, + "facts": { + "forge-host": { + "path": "/etc/gitea/forge-host", + "template": "{{range .Machines}}{{if eq .Name $.Node}}{{.FQDN}}{{end}}{{end}}\n" + } + }, "resources": [ { "id": "mesh-state", @@ -313,6 +319,13 @@ "agent-read-teams" ] }, + { + "id": "consumers", + "type": "file", + "path": "${dir:state}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" + }, { "id": "agent-teams", "type": "process", @@ -353,6 +366,10 @@ "issued-by": "software", "tool": "gitea_agent_token" } + }, + { + "name": "forge-account", + "scope": "mesh" } ], "build": { @@ -451,6 +468,25 @@ "loads": [ "agent-teams" ] + }, + { + "name": "forge-account-provider", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/forge-account-provider", + "binary": "forge-account-provider", + "loads": [ + "forge-account-provider" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins", + "MESH_GITEA_SSH_PORT": "222", + "MESH_FORGE_HOST_FILE": "/etc/gitea/forge-host" + } } ] }, @@ -460,5 +496,11 @@ "failregex": "^.*Failed authentication attempt for .* from (?::\\d+)?\\s*$\n ^.*Invalid user .* from port \\d+\\s*$\n ^.*User \\S+ from not allowed because .*$", "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" } + ], + "state": [ + "bound" + ], + "reads": [ + "ssh-client.keys" ] } -- 2.54.0 From 5c64b71cd751228adb991b887ddc3800ec2ed5f3 Mon Sep 17 00:00:00 2001 From: "fix-955-forge@novox" Date: Fri, 16 Oct 2026 22:47:59 +0200 Subject: [PATCH 09/12] Test the forge-account provider whole against a real bus and a fake forge (issue 955 F5) --- .../cmd/forge-account-provider/bus_test.go | 335 ++++++++++++++++++ modules/gitea/go.mod | 16 +- modules/gitea/go.sum | 27 +- 3 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 modules/gitea/cmd/forge-account-provider/bus_test.go diff --git a/modules/gitea/cmd/forge-account-provider/bus_test.go b/modules/gitea/cmd/forge-account-provider/bus_test.go new file mode 100644 index 00000000..b332cae7 --- /dev/null +++ b/modules/gitea/cmd/forge-account-provider/bus_test.go @@ -0,0 +1,335 @@ +package main + +// The provider whole, against a real bus (issue 955 F5): a JetStream server is linked into the test and started for +// it alone, and the provider is this test binary started again as the bundle, speaking MCP on its standard streams +// to a runtime played here, which does for the bundle's state what the tool runner does: each state verb and each +// watch on the bucket the controller makes from the manifest. The forge is a fake (an httptest server), and the +// forge's own sshd is a program named ssh-keyscan on the bundle's PATH. +// +// It starts a server and a child process, so it is left to the merge check: run the unit tests with -short. + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "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" + + "git.novox.be/novox/mesh-sdk/go/provider" +) + +const bundleVar = "MESH_FORGE_ACCOUNT_TEST_BUNDLE" + +func TestMain(m *testing.M) { + if os.Getenv(bundleVar) == "1" { + main() + os.Exit(0) + } + os.Exit(m.Run()) +} + +type runtime struct { + t *testing.T + js jetstream.JetStream + kvs map[string]jetstream.KeyValue + in io.WriteCloser + mu sync.Mutex + seq int + ctx context.Context + // answers to what the runtime asked the bundle, by id. + wait map[string]chan json.RawMessage +} + +func startBus(t *testing.T) (*runtime, context.CancelFunc) { + t.Helper() + 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) { + s.Shutdown() + t.Fatal("a bus for this test did not come up 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, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + rt := &runtime{t: t, js: js, kvs: map[string]jetstream.KeyValue{}, ctx: ctx, wait: map[string]chan json.RawMessage{}} + // The controller's job: a bucket per state the module declares (bound) or reads (the others). + for state, bucket := range map[string]string{"bound": "gitea_bound", "ssh-client.keys": "ssh-client_keys", + "mesh-controller.assignments": "mesh-controller_assignments"} { + kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucket, History: 1}) + if err != nil { + t.Fatal(err) + } + rt.kvs[state] = kv + } + return rt, cancel +} + +type rpc struct { + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` +} + +func (r *runtime) 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 *runtime) start(env []string) { + r.t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^$") + cmd.Env = append(os.Environ(), append(env, bundleVar+"=1")...) + in, err := cmd.StdinPipe() + if err != nil { + r.t.Fatal(err) + } + out, err := cmd.StdoutPipe() + if err != nil { + r.t.Fatal(err) + } + cmd.Stderr = testWriter{r.t} + if err := cmd.Start(); err != nil { + r.t.Fatal(err) + } + r.in = in + go func() { + sc := bufio.NewScanner(out) + sc.Buffer(make([]byte, 1<<20), 1<<24) + for sc.Scan() { + var m rpc + if json.Unmarshal(sc.Bytes(), &m) != nil { + continue + } + if m.Method == "" { + var id string + _ = json.Unmarshal(m.ID, &id) + r.mu.Lock() + ch := r.wait[id] + delete(r.wait, id) + r.mu.Unlock() + if ch != nil { + ch <- m.Result + } + continue + } + go r.answer(m) + } + }() + r.t.Cleanup(func() { _ = in.Close(); _ = cmd.Wait() }) +} + +type testWriter struct{ t *testing.T } + +func (w testWriter) Write(p []byte) (int, error) { w.t.Logf("bundle: %s", p); return len(p), nil } + +// push tells the bundle one change of a state it watches, and waits until it has taken it. +func (r *runtime) push(watch string, c map[string]any) { + r.mu.Lock() + r.seq++ + id := "runtime-" + strconv.Itoa(r.seq) + ch := make(chan json.RawMessage, 1) + r.wait[id] = ch + r.mu.Unlock() + r.say(map[string]any{"id": id, "method": "mesh/state", "params": map[string]any{"watch": watch, "change": c}}) + select { + case <-ch: + case <-time.After(20 * time.Second): + r.t.Errorf("the bundle did not take a change of %v in 20 s", c["key"]) + } +} + +func (r *runtime) answer(m rpc) { + var p struct { + State string `json:"state"` + Key string `json:"key"` + Value json.RawMessage `json:"value"` + } + _ = json.Unmarshal(m.Params, &p) + result, err := func() (any, error) { + kv := r.kvs[p.State] + if kv == nil { + return nil, fmt.Errorf("the state %q is not one this bundle declares or reads", p.State) + } + switch m.Method { + case "mesh/state.get": + e, err := kv.Get(r.ctx, p.Key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return map[string]any{"key": p.Key, "value": json.RawMessage(e.Value()), "revision": e.Revision()}, nil + case "mesh/state.put": + rev, err := kv.Put(r.ctx, p.Key, p.Value) + return map[string]any{"revision": rev}, err + case "mesh/state.delete": + err := kv.Delete(r.ctx, p.Key) + return map[string]any{}, err + case "mesh/state.watch": + w, err := kv.WatchAll(r.ctx) + if err != nil { + return nil, err + } + id := strings.Trim(string(m.ID), `"`) + for { + var e jetstream.KeyValueEntry + select { + case e = <-w.Updates(): + case <-r.ctx.Done(): + return nil, r.ctx.Err() + } + if e == nil { // the current values are all handed + break + } + if e.Operation() == jetstream.KeyValuePut { + r.push(id, map[string]any{"state": p.State, "key": e.Key(), "op": "put", "revision": e.Revision(), + "current": true, "value": json.RawMessage(e.Value())}) + } + } + go func() { + for e := range w.Updates() { + change := map[string]any{"state": p.State, "key": e.Key(), "op": "put", "revision": e.Revision(), "current": false, + "value": json.RawMessage(e.Value())} + if e.Operation() != jetstream.KeyValuePut { + change["op"] = "delete" + delete(change, "value") + } + r.push(id, change) + } + }() + return map[string]any{}, nil + } + return nil, fmt.Errorf("the runtime offers no %s to this bundle", m.Method) + }() + if err != nil { + r.say(map[string]any{"id": m.ID, "error": map[string]any{"code": -32000, "message": err.Error()}}) + return + } + r.say(map[string]any{"id": m.ID, "result": result}) +} + +func (r *runtime) put(state, key string, v any) { + r.t.Helper() + raw, _ := json.Marshal(v) + if _, err := r.kvs[state].Put(r.ctx, key, raw); err != nil { + r.t.Fatal(err) + } +} + +func (r *runtime) boundEntry(key string) (provider.Entry, bool) { + e, err := r.kvs["bound"].Get(r.ctx, key) + if err != nil { + return provider.Entry{}, false + } + var out provider.Entry + _ = json.Unmarshal(e.Value(), &out) + return out, true +} + +func eventually(t *testing.T, what string, ok func() bool) { + t.Helper() + for deadline := time.Now().Add(40 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + if ok() { + return + } + } + t.Fatalf("not within 40 s: %s", what) +} + +func TestTheProviderWholeAnswersAMachineThatReportedItsKey(t *testing.T) { + if testing.Short() { + t.Skip("starts a bus and a process: left to the merge check") + } + forge := newForge() + srv := httptest.NewServer(forge) + t.Cleanup(srv.Close) + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + if err := os.Mkdir(bin, 0o755); err != nil { + t.Fatal(err) + } + files := map[string]string{ + "pw": "x\n", + "host": "novox.internal\n", + "origins": "https://git.novox.be\n", + "bin/ssh-keyscan": "#!/bin/sh\necho '[127.0.0.1]:222 ssh-ed25519 AAAAC3HOSTKEY'\n", + } + for name, c := range files { + mode := os.FileMode(0o600) + if strings.HasPrefix(name, "bin/") { + mode = 0o755 + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(c), mode); err != nil { + t.Fatal(err) + } + } + rt, cancel := startBus(t) + t.Cleanup(cancel) + rt.put("mesh-controller.assignments", "g14.ssh-client", map[string]any{"node": "g14", "module": "ssh-client", + "provisioning": map[string]any{"provisions": []map[string]any{{"provision": "forge-account", "provider": "novox", + "provider-module": "gitea", "as": "mesh_g14_ssh_client"}}}}) + rt.put("ssh-client.keys", "g14.nox", map[string]string{"node": "g14", "account": "nox", "kind": "machine", + "public": pubA + " nox@g14 (mesh)", "fingerprint": "SHA256:x", "at": "2026-10-16T10:00:00Z"}) + rt.start([]string{"MESH_NODE=novox", "MESH_MODULE=gitea", "MESH_SERVED_MODULE=gitea", + "PATH=" + bin + ":" + os.Getenv("PATH"), + "MESH_GITEA_URL=" + srv.URL, "MESH_GITEA_ADMIN_USER=mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE=" + filepath.Join(dir, "pw"), + "MESH_GITEA_ORIGINS_FILE=" + filepath.Join(dir, "origins"), + "MESH_FORGE_HOST_FILE=" + filepath.Join(dir, "host"), "MESH_GITEA_SSH_PORT=222"}) + + var e provider.Entry + eventually(t, "the bound entry of g14's ssh-client", func() bool { + var ok bool + e, ok = rt.boundEntry("g14.ssh-client.forge-account") + return ok + }) + if e.Values["user"] != "agent-g14" || e.Values["ssh_host"] != "novox.internal" || + e.Values["known_host"] != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" || + e.Values["ssh_base"] != "ssh://git@novox.internal:222/" || e.Values["https_base"] != "https://git.novox.be/" { + t.Fatalf("values = %v", e.Values) + } + if got := forge.titles("agent-g14"); len(got) != 1 || got[0] != "mesh:g14.nox" { + t.Fatalf("registered keys = %v", got) + } + + // A key that changes is answered without waiting for the next pass. + rt.put("ssh-client.keys", "g14.nox", map[string]string{"node": "g14", "account": "nox", "kind": "machine", + "public": pubB + " nox@g14 (mesh)", "fingerprint": "SHA256:y", "at": "2026-10-16T11:00:00Z"}) + eventually(t, "the new key registered and the old one gone", func() bool { + forge.mu.Lock() + defer forge.mu.Unlock() + k := forge.held["agent-g14"] + return len(k) == 1 && strings.HasPrefix(k[0]["key"].(string), pubB) + }) +} diff --git a/modules/gitea/go.mod b/modules/gitea/go.mod index 0fa6eee9..6ac66ac9 100644 --- a/modules/gitea/go.mod +++ b/modules/gitea/go.mod @@ -2,9 +2,21 @@ module gitea go 1.25.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 + github.com/nats-io/nats-server/v2 v2.11.17 + github.com/nats-io/nats.go v1.51.0 + golang.org/x/crypto v0.50.0 +) require ( - golang.org/x/crypto v0.50.0 + 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/sys v0.43.0 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/modules/gitea/go.sum b/modules/gitea/go.sum index 735d7c6c..83c75127 100644 --- a/modules/gitea/go.sum +++ b/modules/gitea/go.sum @@ -1,6 +1,27 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -- 2.54.0 From 6fdf3629d3a4da6e62c0cae77cc1bd91285fd217 Mon Sep 17 00:00:00 2001 From: "fix-955-forge@novox" Date: Fri, 16 Oct 2026 22:53:22 +0200 Subject: [PATCH 10/12] Remove a machine's mesh keys and entry when its key is no longer reported (issue 955 F) --- .../cmd/forge-account-provider/service.go | 16 +++++++++++++++- .../cmd/forge-account-provider/service_test.go | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/modules/gitea/cmd/forge-account-provider/service.go b/modules/gitea/cmd/forge-account-provider/service.go index b9975ea6..2be1deec 100644 --- a/modules/gitea/cmd/forge-account-provider/service.go +++ b/modules/gitea/cmd/forge-account-provider/service.go @@ -66,6 +66,8 @@ type Service struct { keys map[string]machineKey known string // the forge's host key line, as the machines write it into known_hosts + bound provider.Bus // the bus the entries are written to (set by Provider) + making sync.Mutex // one Make at a time: they register keys, and two at once would race each other } @@ -85,10 +87,12 @@ func NewService(cfg Config) *Service { // Provider is the provider helper answering the consumers over bus. Every consumer is made again every ten minutes, // which puts back an account, its team or a key changed by hand. func (s *Service) Provider(bus provider.Bus) *provider.Provider { + vb := &valuesBus{Bus: bus, s: s, written: map[string][]byte{}} + s.bound = vb return provider.New(provider.Options{ Node: s.cfg.Node, Module: Module, Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.NoCredential}}, - Bus: &valuesBus{Bus: bus, s: s, written: map[string][]byte{}}, + Bus: vb, Make: s.Make, Gone: s.Gone, Every: 10 * time.Minute, Logf: s.cfg.Log, @@ -115,6 +119,16 @@ func (s *Service) Make(ctx context.Context, c provider.Consumer) error { } have := s.keysOf(node) if len(have) == 0 { + // A key the machine no longer reports is no longer wanted (ADR 0400 §2): the mesh's keys go, the operator's + // stay, and the entry that served the machine goes with them. + if err := s.accounts.RemoveKeysTitled(ctx, user, forgeaccount.TitlePrefix+node+"."); err != nil { + return err + } + if s.bound != nil { + if err := s.bound.Delete(ctx, provider.BucketOf(Module), c.Entry.Key()); err != nil { + return err + } + } return errNoKey{node} } if _, err := s.hostName(); err != nil { diff --git a/modules/gitea/cmd/forge-account-provider/service_test.go b/modules/gitea/cmd/forge-account-provider/service_test.go index fdfdf949..fa5d7a8a 100644 --- a/modules/gitea/cmd/forge-account-provider/service_test.go +++ b/modules/gitea/cmd/forge-account-provider/service_test.go @@ -415,3 +415,21 @@ func (f *fakeKeys) Watch(handle func(key, op string, value []byte, current bool) return nil } func (f *fakeKeys) send(key, op string, v []byte, current bool) { f.handle(key, op, v, current) } + +func TestADeletedKeyEntryRemovesTheMeshKeyAndTheBoundEntryButNotTheOperatorsKey(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + r.key("g14", "nox", pubA+" nox@g14 (mesh)") + r.pass() + r.forge.mu.Lock() + r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB}) + r.forge.mu.Unlock() + r.svc.KeyGone("g14.nox") + r.pass() + if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "my own" { + t.Fatalf("keys = %v", got) + } + if r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("the entry still serves a machine whose key is gone") + } +} -- 2.54.0 From 6f45570d4c23f5248144e2ffef5d4c7e920ba086 Mon Sep 17 00:00:00 2001 From: "fix-955-forge@novox" Date: Fri, 16 Oct 2026 22:54:31 +0200 Subject: [PATCH 11/12] Do not remove keys or entries before the machines' keys are read (issue 955 F) --- .../cmd/forge-account-provider/service.go | 18 +++++++-- .../forge-account-provider/service_test.go | 38 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/modules/gitea/cmd/forge-account-provider/service.go b/modules/gitea/cmd/forge-account-provider/service.go index 2be1deec..b6f391ad 100644 --- a/modules/gitea/cmd/forge-account-provider/service.go +++ b/modules/gitea/cmd/forge-account-provider/service.go @@ -62,9 +62,10 @@ type Service struct { // Pause waits between asks of a watch the runtime does not answer yet. Pause func(time.Duration) - mu sync.Mutex - keys map[string]machineKey - known string // the forge's host key line, as the machines write it into known_hosts + mu sync.Mutex + keys map[string]machineKey + loaded bool // the machines' keys have been read: until then a node with none has not reported none + known string // the forge's host key line, as the machines write it into known_hosts bound provider.Bus // the bus the entries are written to (set by Provider) @@ -117,6 +118,13 @@ func (s *Service) Make(ctx context.Context, c provider.Consumer) error { if user == "" { return fmt.Errorf("%q is not a machine's name an account can be named for", node) } + s.mu.Lock() + loaded := s.loaded + s.mu.Unlock() + if !loaded { + // After a restart the first pass can come before the keys arrive: nothing is removed or deleted on that. + return errors.New("the machines' keys are not read yet, so nothing is made, removed or deleted") + } have := s.keysOf(node) if len(have) == 0 { // A key the machine no longer reports is no longer wanted (ADR 0400 §2): the mesh's keys go, the operator's @@ -253,6 +261,10 @@ func (s *Service) WatchKeys(ctx context.Context, src KeySource) error { } }) if err == nil { + // The watch answers once the current values are all handled (stdio KeptState.Watch). + s.mu.Lock() + s.loaded = true + s.mu.Unlock() return nil } pause := time.Minute diff --git a/modules/gitea/cmd/forge-account-provider/service_test.go b/modules/gitea/cmd/forge-account-provider/service_test.go index fa5d7a8a..ebc52291 100644 --- a/modules/gitea/cmd/forge-account-provider/service_test.go +++ b/modules/gitea/cmd/forge-account-provider/service_test.go @@ -213,6 +213,7 @@ func newRig(t *testing.T) *rig { svc := NewService(Config{Node: "novox", HostFile: host, OriginsFile: origins, Port: 222, Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Runner: runner, Log: func(string, ...any) {}}) + svc.loaded = true // the machines' keys are read; a test that means otherwise says so return &rig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner} } @@ -377,11 +378,15 @@ func TestKeysWatchedRetriesUntilItAnswersAndPassesOnChanges(t *testing.T) { r := newRig(t) src := &fakeKeys{failFirst: 2} r.svc.Pause = func(time.Duration) {} + r.svc.loaded = false passes := 0 r.svc.OnKeys = func(string) { passes++ } if err := r.svc.WatchKeys(context.Background(), src); err != nil { t.Fatal(err) } + if !r.svc.loaded { + t.Fatal("the keys are not loaded once the watch has answered") + } if src.calls != 3 { t.Fatalf("asked %d times", src.calls) } @@ -433,3 +438,36 @@ func TestADeletedKeyEntryRemovesTheMeshKeyAndTheBoundEntryButNotTheOperatorsKey( t.Fatal("the entry still serves a machine whose key is gone") } } + +func TestAPassBeforeTheKeysAreLoadedRemovesAndDeletesNothing(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + r.key("g14", "nox", pubA+" nox@g14 (mesh)") + r.pass() + r.forge.mu.Lock() + r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB}) + r.forge.mu.Unlock() + // A restart: the keys are not read yet, so a node with none is not a node that reported none. + r.svc.mu.Lock() + r.svc.keys = map[string]machineKey{} + r.svc.loaded = false + r.svc.mu.Unlock() + out := r.pass() + if got := strings.Join(r.forge.titles("agent-g14"), ","); got != "mesh:g14.nox,my own" { + t.Fatalf("keys = %s", got) + } + if !r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("the entry was deleted before the keys were read") + } + if len(out) != 1 || !strings.Contains(out[0].Why, "not read yet") { + t.Fatalf("the wait should be said: %+v", out) + } + // Loaded, and the node reports none: now its keys go. + r.svc.mu.Lock() + r.svc.loaded = true + r.svc.mu.Unlock() + r.pass() + if got := strings.Join(r.forge.titles("agent-g14"), ","); got != "my own" || r.bus.has("g14.ssh-client.forge-account") { + t.Fatalf("keys = %s", got) + } +} -- 2.54.0 From ae9cda8531b3fbf7d7c240f15bafef8856822c4b Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Fri, 16 Oct 2026 22:05:52 +0000 Subject: [PATCH 12/12] Hand ssh-client's forge files to the provisioner client (issue 955) --- modules/ssh-client/module.json | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/ssh-client/module.json b/modules/ssh-client/module.json index 44cc6086..d4780c2f 100644 --- a/modules/ssh-client/module.json +++ b/modules/ssh-client/module.json @@ -1,6 +1,7 @@ { "module": "ssh-client", "version": "1", + "moved": true, "tools": [ "ssh_client_hosts", "ssh_client_resolve", -- 2.54.0