Merge pull request 'Feature agents-write-git, step 2: git over SSH with a key made on each machine (issue 955)' (#340) from feat/agents-write-git-ssh into main

This commit was merged in pull request #340.
This commit is contained in:
2026-10-16 22:31:17 +00:00
26 changed files with 2687 additions and 121 deletions
+12
View File
@@ -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 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 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. 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-<node>` in the agents team
(`internal/forgeaccount`, the code `cmd/agent-token` shares), registers the key under the title `mesh:<node>.<account>`,
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.
+15 -111
View File
@@ -9,7 +9,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"regexp"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -17,6 +16,7 @@ import (
"golang.org/x/crypto/nacl/box" "golang.org/x/crypto/nacl/box"
"gitea/internal/forgeaccount"
"gitea/internal/forgehttp" "gitea/internal/forgehttp"
) )
@@ -31,7 +31,7 @@ const (
// TokenName is the token's name in the agent account's list, found and deleted by it at the next mint. // 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" TokenName = "mesh-agent-read"
// AccountPrefix names an agent's account for its node (ADR 0365 §1). // 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 `<node>.mesh-vault` (ADR 0259 §8's runs-as). // vaultModule is the vault's module: its account is `<node>.mesh-vault` (ADR 0259 §8's runs-as).
vaultModule = "mesh-vault" vaultModule = "mesh-vault"
// deadline bounds one call, inside the vault's ask. // deadline bounds one call, inside the vault's ask.
@@ -43,23 +43,13 @@ const (
// the agents team's (a write team on every repository); main takes no push whatever the token (ADR 0398 §2). // 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"} var TokenScopes = []string{"write:repository", "write:issue"}
// The agents team and its shape are the forgeaccount package's (ADR 0398 §1).
const ( const (
// TeamOrg is the organisation whose repositories are the mesh's. TeamOrg = forgeaccount.TeamOrg
TeamOrg = "novox" TeamName = forgeaccount.TeamName
// 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 var nodeName = forgeaccount.NodeName
// 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 // 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. // tool would hold every agent's credential.
@@ -205,11 +195,11 @@ func (m *Minter) Answer(ask Ask) (Answer, error) {
skip(h, "the forge names no origin it is reached at from that machine") skip(h, "the forge names no origin it is reached at from that machine")
continue continue
} }
account := AccountPrefix + h.Node account := forgeaccount.ForgeUser(h.Node, "machine")
if err := m.ensureAccount(ctx, account); err != nil { if err := m.accounts().EnsureAccount(ctx, account); err != nil {
return Answer{}, err return Answer{}, err
} }
if err := m.ensureInTeam(ctx, account); err != nil { if err := m.accounts().EnsureInTeam(ctx, account); err != nil {
return Answer{}, err return Answer{}, err
} }
token, err := m.mint(ctx, account) token, err := m.mint(ctx, account)
@@ -274,91 +264,6 @@ func credentialLines(account, token string, origins []string) (string, error) {
return strings.Join(lines, "\n") + "\n", nil 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 { type forgeToken struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -392,13 +297,9 @@ func (m *Minter) mint(ctx context.Context, account string) (string, error) {
return strings.TrimSpace(made.SHA1), nil return strings.TrimSpace(made.SHA1), nil
} }
func (m *Minter) password() string { // accounts is the forge's accounts as this minter reaches them (ADR 0400 §3: one place says what an agent's account is).
if m.Password != nil { func (m *Minter) accounts() *forgeaccount.Accounts {
return m.Password() return &forgeaccount.Accounts{Forge: m.Forge, Password: m.Password, Log: m.Log}
}
b := make([]byte, 24)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
} }
func (m *Minter) say(format string, a ...any) { func (m *Minter) say(format string, a ...any) {
@@ -408,3 +309,6 @@ func (m *Minter) say(format string, a ...any) {
} }
fmt.Fprintf(os.Stderr, format+"\n", a...) 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
@@ -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)
})
}
@@ -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-<node>, 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)
}
}
@@ -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)
}
}
@@ -0,0 +1,409 @@
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
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)
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 {
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: vb,
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)
}
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
// 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 {
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 {
// 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
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)
}
@@ -0,0 +1,473 @@
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) {}})
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}
}
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) {}
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)
}
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) }
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")
}
}
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)
}
}
+1
View File
@@ -0,0 +1 @@
${consumers:forge-account}
+14 -2
View File
@@ -2,9 +2,21 @@ module gitea
go 1.25.0 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
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 ( 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/sys v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
) )
+25 -4
View File
@@ -1,6 +1,27 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= 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=
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/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/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=
@@ -0,0 +1,158 @@
// 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"
"regexp"
"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...)
}
// 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-<node>` 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 ""
}
+155
View File
@@ -0,0 +1,155 @@
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:<node>.<account>`.
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
}
// 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
}
@@ -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)
}
}
@@ -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")
}
}
+42
View File
@@ -177,6 +177,12 @@
"default": "mesh-tools mesh-admin jschoubben" "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": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
@@ -313,6 +319,13 @@
"agent-read-teams" "agent-read-teams"
] ]
}, },
{
"id": "consumers",
"type": "file",
"path": "${dir:state}/consumers.json",
"mode": "0600",
"template": "files/consumers.json"
},
{ {
"id": "agent-teams", "id": "agent-teams",
"type": "process", "type": "process",
@@ -353,6 +366,10 @@
"issued-by": "software", "issued-by": "software",
"tool": "gitea_agent_token" "tool": "gitea_agent_token"
} }
},
{
"name": "forge-account",
"scope": "mesh"
} }
], ],
"build": { "build": {
@@ -451,6 +468,25 @@
"loads": [ "loads": [
"agent-teams" "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 <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$", "failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
} }
],
"state": [
"bound"
],
"reads": [
"ssh-client.keys"
] ]
} }
@@ -0,0 +1,115 @@
package main
// The key this machine's account uses to reach the forge over SSH (novox/hq ADR 0399, ADR 0400; issue 955,
// area S1). It is made here, on the machine, once: ssh-keygen writes ~/.ssh/mesh_ed25519 only when it is
// missing, so the private half never leaves the machine and an existing key is never replaced. The public
// half and its fingerprint go into this module's `keys` state, one entry per account under <node>.<account>,
// for the forge's provider to register. The tools bundle is the one process of this module, so the keeper
// runs inside it beside the tools (main.go), the way claude-code reports what it holds.
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// KeyFile is the key's name under ~/.ssh: never one of the operator's own id_* files.
const KeyFile = "mesh_ed25519"
// KeyKind is the kind of key this process makes: the machine's account's own (ADR 0400 §1).
const KeyKind = "machine"
// KeyRetry is how soon it looks again after a failure.
const KeyRetry = 30 * time.Second
// KeyRound is how often the keeper looks again.
const KeyRound = 10 * time.Minute
// KeyEntry is one value of the keys state. It carries the public half only.
type KeyEntry struct {
Node string `json:"node"`
Account string `json:"account"`
Kind string `json:"kind"`
Public string `json:"public"`
Fingerprint string `json:"fingerprint"`
At string `json:"at"`
}
// KeyPutter is the keys state, as far as the keeper needs it.
type KeyPutter interface {
Put(key string, value any) error
}
// KeyKeeper keeps one account's key and its entry.
type KeyKeeper struct {
Home, Node, Account string
Run Runner
State KeyPutter
Now func() time.Time
reported string // the public half and fingerprint last put
}
func (k *KeyKeeper) path() string { return filepath.Join(k.Home, ".ssh", KeyFile) }
// Once makes the key when it is missing and puts the entry when it differs from the one last put.
func (k *KeyKeeper) Once(ctx context.Context) error {
if err := k.ensure(ctx); err != nil {
return err
}
raw, err := os.ReadFile(k.path() + ".pub")
if err != nil {
return fmt.Errorf("the public half: %w", err)
}
pk, err := ParseKeyLine(string(raw))
if err != nil {
return fmt.Errorf("%s.pub: %w", k.path(), err)
}
public := strings.TrimSpace(string(raw))
if sig := public + "|" + pk.Fingerprint; sig == k.reported {
return nil
}
entry := KeyEntry{Node: k.Node, Account: k.Account, Kind: KeyKind, Public: public,
Fingerprint: pk.Fingerprint, At: k.Now().UTC().Format(time.RFC3339)}
if err := k.State.Put(k.Node+"."+k.Account, entry); err != nil {
return err
}
k.reported = public + "|" + pk.Fingerprint
return nil
}
// ensure makes the pair when the private half is missing. Never over an existing key.
func (k *KeyKeeper) ensure(ctx context.Context) error {
if _, err := os.Lstat(k.path()); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(filepath.Dir(k.path()), 0o700); err != nil {
return err
}
r := k.Run(ctx, nil, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", k.Account+"@"+k.Node+" (mesh)", "-f", k.path())
if r.Status != 0 || r.Err != "" {
return fmt.Errorf("ssh-keygen: %s", firstLine(r.Err+" "+r.Stderr))
}
return nil
}
// Keep runs Once now and every KeyRound, reporting a failure through say and trying again at the next round.
func (k *KeyKeeper) Keep(ctx context.Context, say func(string, ...any)) {
for {
pause := KeyRound
if err := k.Once(ctx); err != nil {
say("the forge key: %v", err)
pause = KeyRetry // the state or the bus's grant may arrive after the process
}
select {
case <-ctx.Done():
return
case <-time.After(pause):
}
}
}
@@ -0,0 +1,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 <node>.<account>" — 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())
}
}
@@ -0,0 +1,140 @@
package main
import (
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Issue 955, area S1: the key the machine's account makes for the forge, and its entry in the keys state.
type fakeState struct{ puts map[string][]byte }
func (f *fakeState) Put(key string, value any) error {
raw, _ := json.Marshal(value)
if f.puts == nil {
f.puts = map[string][]byte{}
}
f.puts[key] = raw
return nil
}
// ed25519Line is a public key line with a well-formed ed25519 blob.
func ed25519Line(seed byte, comment string) string {
blob := []byte{}
for _, part := range [][]byte{[]byte("ssh-ed25519"), make([]byte, 32)} {
part2 := append([]byte(nil), part...)
if len(part2) == 32 {
part2[0] = seed
}
l := make([]byte, 4)
binary.BigEndian.PutUint32(l, uint32(len(part2)))
blob = append(append(blob, l...), part2...)
}
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(blob) + " " + comment
}
// keygenRunner stands in for ssh-keygen -t ed25519: it writes the pair the command names.
func keygenRunner(calls *[][]string) Runner {
return func(ctx context.Context, env []string, name string, args ...string) Ran {
*calls = append(*calls, append([]string{name}, args...))
var file, comment string
for i, a := range args {
switch a {
case "-f":
file = args[i+1]
case "-C":
comment = args[i+1]
}
}
if name != "ssh-keygen" || file == "" {
return Ran{Status: 1, Stderr: "unexpected"}
}
os.WriteFile(file, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE-SECRET\n"), 0o600)
os.WriteFile(file+".pub", []byte(ed25519Line(7, comment)+"\n"), 0o644)
return Ran{}
}
}
func newKeeper(t *testing.T, home string, st *fakeState, calls *[][]string) *KeyKeeper {
return &KeyKeeper{Home: home, Node: "g14", Account: "nox", Run: keygenRunner(calls), State: st,
Now: func() time.Time { return time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) }}
}
func TestKeeperMakesTheKeyAndWritesItsEntry(t *testing.T) {
home := t.TempDir()
st, calls := &fakeState{}, &[][]string{}
if err := newKeeper(t, home, st, calls).Once(context.Background()); err != nil {
t.Fatal(err)
}
raw, ok := st.puts["g14.nox"]
if !ok || len(st.puts) != 1 {
t.Fatalf("entries = %v, want one under g14.nox", st.puts)
}
var e map[string]string
json.Unmarshal(raw, &e)
if e["node"] != "g14" || e["account"] != "nox" || e["kind"] != "machine" || e["at"] != "2026-10-16T12:00:00Z" {
t.Errorf("entry = %v", e)
}
if !strings.HasPrefix(e["public"], "ssh-ed25519 ") || !strings.HasSuffix(e["public"], " nox@g14 (mesh)") {
t.Errorf("public = %q", e["public"])
}
if !strings.HasPrefix(e["fingerprint"], "SHA256:") {
t.Errorf("fingerprint = %q", e["fingerprint"])
}
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
t.Errorf("the entry carries private material: %s", raw)
}
cmd := strings.Join((*calls)[0], " ")
for _, want := range []string{"-t ed25519", "-N ", "-C nox@g14 (mesh)", filepath.Join(home, ".ssh", "mesh_ed25519")} {
if !strings.Contains(cmd, want) {
t.Errorf("ssh-keygen call %q lacks %q", cmd, want)
}
}
}
func TestKeeperKeepsAnExistingKeyByteForByte(t *testing.T) {
home := t.TempDir()
os.MkdirAll(filepath.Join(home, ".ssh"), 0o700)
priv, pub := filepath.Join(home, ".ssh", "mesh_ed25519"), filepath.Join(home, ".ssh", "mesh_ed25519.pub")
os.WriteFile(priv, []byte("mine"), 0o600)
os.WriteFile(pub, []byte(ed25519Line(9, "nox@g14 (mesh)")+"\n"), 0o644)
st, calls := &fakeState{}, &[][]string{}
k := newKeeper(t, home, st, calls)
if err := k.Once(context.Background()); err != nil {
t.Fatal(err)
}
if len(*calls) != 0 {
t.Errorf("ssh-keygen ran over an existing key: %v", *calls)
}
if b, _ := os.ReadFile(priv); string(b) != "mine" {
t.Errorf("private file changed: %q", b)
}
if _, ok := st.puts["g14.nox"]; !ok {
t.Errorf("no entry for the existing key")
}
// A second round with nothing changed writes nothing.
delete(st.puts, "g14.nox")
k.Once(context.Background())
if len(st.puts) != 0 {
t.Errorf("an unchanged key was put again")
}
}
func TestKeeperNeverTouchesTheOperatorsOwnKeys(t *testing.T) {
home := t.TempDir()
os.MkdirAll(filepath.Join(home, ".ssh"), 0o700)
own := filepath.Join(home, ".ssh", "id_ed25519")
os.WriteFile(own, []byte("operator"), 0o600)
st, calls := &fakeState{}, &[][]string{}
newKeeper(t, home, st, calls).Once(context.Background())
if b, _ := os.ReadFile(own); string(b) != "operator" {
t.Errorf("the operator's key changed")
}
}
@@ -10,12 +10,15 @@ import (
"fmt" "fmt"
"math" "math"
"os" "os"
"os/user"
"strings" "strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go" stdio "git.novox.be/novox/mesh-sdk/go"
) )
func main() { func main() {
go startKeeper()
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): ssh-client. // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): ssh-client.
if err := stdio.Serve("", tools(NewClient())); err != nil { if err := stdio.Serve("", tools(NewClient())); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
@@ -136,3 +139,23 @@ func flag(args map[string]any, key string, def bool) bool {
} }
return def 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
}
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -62,3 +63,115 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
t.Errorf("the build packs files/ into %d archives, not one", packs) t.Errorf("the build packs files/ into %d archives, not one", packs)
} }
} }
// Issue 955, area S2: the module keeps the keys state and requires the forge's account provision. It declares
// no provision `ssh-key`: the state serves the public half (novox/hq ADR 0400 §1).
func TestManifestKeepsKeysAndRequiresForgeAccount(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
State []string `json:"state"`
Requires []any `json:"requires"`
Provides []any `json:"provides"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if len(m.State) != 1 || m.State[0] != "keys" {
t.Errorf("state = %v, want [keys]", m.State)
}
if len(m.Requires) != 1 || m.Requires[0] != "forge-account" {
t.Errorf("requires = %v, want [forge-account]", m.Requires)
}
if len(m.Provides) != 0 {
t.Errorf("provides = %v, want none (no ssh-key provision yet)", m.Provides)
}
}
// Issue 955, area S3: the three files the forge account fills in — known_hosts, the forge's Host, git's rewrite.
var sampleBound = map[string]string{
"known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3Nza",
"ssh_host": "novox.internal",
"ssh_port": "222",
"ssh_base": "ssh://git@novox.internal:222/",
"https_base": "https://git.novox.be/",
}
// renderBound fills ${bound:forge-account:<key>} 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/*")
}
}
+5
View File
@@ -0,0 +1,5 @@
Host ${bound:forge-account:ssh_host}
Port ${bound:forge-account:ssh_port}
User git
IdentityFile ~/.ssh/mesh_ed25519
IdentitiesOnly yes
+2
View File
@@ -0,0 +1,2 @@
[url "${bound:forge-account:ssh_base}"]
insteadOf = ${bound:forge-account:https_base}
+1
View File
@@ -0,0 +1 @@
${bound:forge-account:known_host}
+19 -2
View File
@@ -1,5 +1,22 @@
module sshclient module sshclient
go 1.22 go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.6 require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.51.0
)
require (
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+27 -2
View File
@@ -1,2 +1,27 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
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/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=
+40
View File
@@ -1,6 +1,7 @@
{ {
"module": "ssh-client", "module": "ssh-client",
"version": "1", "version": "1",
"moved": true,
"tools": [ "tools": [
"ssh_client_hosts", "ssh_client_hosts",
"ssh_client_resolve", "ssh_client_resolve",
@@ -11,6 +12,12 @@
"ssh_client_known_host", "ssh_client_known_host",
"ssh_client_test" "ssh_client_test"
], ],
"requires": [
"forge-account"
],
"state": [
"keys"
],
"data": { "data": {
"own": [ "own": [
{ {
@@ -45,6 +52,39 @@
"into": "block", "into": "block",
"at": "start", "at": "start",
"template": "files/config" "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": { "facts": {