diff --git a/modules/gitea/cmd/agent-token/token.go b/modules/gitea/cmd/agent-token/token.go index e427b632..8b56fc37 100644 --- a/modules/gitea/cmd/agent-token/token.go +++ b/modules/gitea/cmd/agent-token/token.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "os" - "regexp" "sort" "strings" "sync" @@ -50,7 +49,7 @@ const ( TeamName = forgeaccount.TeamName ) -var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) +var nodeName = forgeaccount.NodeName // fromTheVault refuses every caller but the vault's own account (ADR 0349 clause 10): whoever else could call this // tool would hold every agent's credential. @@ -196,7 +195,7 @@ func (m *Minter) Answer(ask Ask) (Answer, error) { skip(h, "the forge names no origin it is reached at from that machine") continue } - account := AccountPrefix + h.Node + account := forgeaccount.ForgeUser(h.Node, "machine") if err := m.accounts().EnsureAccount(ctx, account); err != nil { return Answer{}, err } diff --git a/modules/gitea/cmd/forge-account-provider/main.go b/modules/gitea/cmd/forge-account-provider/main.go new file mode 100644 index 00000000..1f0148a8 --- /dev/null +++ b/modules/gitea/cmd/forge-account-provider/main.go @@ -0,0 +1,126 @@ +// forge-account-provider: gitea as the provider of `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955). A process +// the node's runtime launches and speaks MCP to over stdio, beside the module's other bundles. +// +// For each machine whose ssh-client binds the provision and has reported its public key (ssh-client's state `keys`), +// it makes the machine's agent a forge account of its own (agent-, in the agents team), registers the key for +// it, and serves where and as whom to reach the forge over ssh: the user, the forge machine's mesh name and port, its +// host key and the two url roots. A public key is no secret, so the provision has no credential. +// +// stdout is the MCP channel; it says what it says on stderr, and never the admin's password. +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/provider" + + "gitea/internal/forgehttp" +) + +func say(format string, args ...any) { + fmt.Fprintf(os.Stderr, "[forge-account] "+format+"\n", args...) +} + +// execRunner runs a program on this machine. +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).Output() + if err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + return out, nil +} + +// stateKeys is ssh-client's state `keys` over the runtime (the manifest's `reads` grants it). +type stateKeys struct{} + +func (stateKeys) Watch(handle func(key, op string, value []byte, current bool)) error { + return stdio.State("ssh-client.keys").Watch("", func(c stdio.StateChange) error { + handle(c.Key, c.Op, c.Value, c.Current) + return nil + }) +} + +// configFromEnv is the service's environment as the manifest hands it, naming what is missing. +func configFromEnv(getenv func(string) string) (Config, error) { + port, perr := strconv.Atoi(getenv("MESH_GITEA_SSH_PORT")) + cfg := Config{ + Node: getenv("MESH_NODE"), + HostFile: getenv("MESH_FORGE_HOST_FILE"), + OriginsFile: getenv("MESH_GITEA_ORIGINS_FILE"), + Port: port, + Forge: forgehttp.Client{URL: strings.TrimRight(getenv("MESH_GITEA_URL"), "/"), User: getenv("MESH_GITEA_ADMIN_USER"), + PasswordFile: getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), Timeout: 20 * time.Second}, + Runner: execRunner{}, + } + var missing []string + for _, kv := range [][2]string{{"MESH_NODE", cfg.Node}, {"MESH_FORGE_HOST_FILE", cfg.HostFile}, + {"MESH_GITEA_ORIGINS_FILE", cfg.OriginsFile}, {"MESH_GITEA_URL", cfg.Forge.URL}, {"MESH_GITEA_ADMIN_USER", cfg.Forge.User}, + {"MESH_GITEA_ADMIN_PASSWORD_FILE", cfg.Forge.PasswordFile}} { + if kv[1] == "" { + missing = append(missing, kv[0]) + } + } + if perr != nil || port <= 0 { + missing = append(missing, "MESH_GITEA_SSH_PORT") + } + if len(missing) > 0 { + return Config{}, fmt.Errorf("the forge-account provider cannot start: %s not set by the manifest", strings.Join(missing, ", ")) + } + return cfg, nil +} + +func main() { + cfg, err := configFromEnv(os.Getenv) + if err != nil { + // Said, not fatal: the runtime would restart a process that cannot do better. + say("%v; providing nothing", err) + if err := stdio.Serve("", nil); err != nil { + say("%v", err) + os.Exit(1) + } + return + } + cfg.Log = say + svc := NewService(cfg) + p := svc.Provider(provider.StateBus(Module)) + ctx := context.Background() + // A pass answers every consumer; one at a time, and one per change of a machine's key. + var passing sync.Mutex + pass := func() { + passing.Lock() + defer passing.Unlock() + if _, err := p.Pass(ctx); err != nil { + say("a pass failed: %v", err) + } + } + svc.OnKeys = func(string) { go pass() } + go func() { + if err := svc.WatchKeys(ctx, stateKeys{}); err != nil { + say("the machines' keys are not watched: %v", err) + return + } + say("watching the machines' keys") + pass() + }() + go func() { + if err := p.Run(ctx); err != nil { + say("the provider stopped: %v", err) + } + }() + if err := stdio.Serve("", nil); err != nil { + say("%v", err) + os.Exit(1) + } +} diff --git a/modules/gitea/cmd/forge-account-provider/main_test.go b/modules/gitea/cmd/forge-account-provider/main_test.go new file mode 100644 index 00000000..d8e04990 --- /dev/null +++ b/modules/gitea/cmd/forge-account-provider/main_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "strings" + "testing" +) + +func TestConfigFromEnvNamesWhatIsMissing(t *testing.T) { + env := map[string]string{"MESH_NODE": "novox", "MESH_FORGE_HOST_FILE": "/h", "MESH_GITEA_ORIGINS_FILE": "/o", + "MESH_GITEA_URL": "http://127.0.0.1:3000/", "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "/p", "MESH_GITEA_SSH_PORT": "222"} + cfg, err := configFromEnv(func(k string) string { return env[k] }) + if err != nil || cfg.Port != 222 || cfg.Forge.URL != "http://127.0.0.1:3000" { + t.Fatalf("%+v %v", cfg, err) + } + delete(env, "MESH_FORGE_HOST_FILE") + env["MESH_GITEA_SSH_PORT"] = "x" + _, err = configFromEnv(func(k string) string { return env[k] }) + if err == nil || !strings.Contains(err.Error(), "MESH_FORGE_HOST_FILE") || !strings.Contains(err.Error(), "MESH_GITEA_SSH_PORT") { + t.Fatalf("%v", err) + } +} diff --git a/modules/gitea/cmd/forge-account-provider/service.go b/modules/gitea/cmd/forge-account-provider/service.go new file mode 100644 index 00000000..b9975ea6 --- /dev/null +++ b/modules/gitea/cmd/forge-account-provider/service.go @@ -0,0 +1,383 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" + + "gitea/internal/forgeaccount" + "gitea/internal/forgehttp" +) + +// ProvisionName is what gitea provides here, as its manifest says (novox/hq ADR 0400). +const ProvisionName = "forge-account" + +// Module is gitea's name in the mesh. +const Module = "gitea" + +// Runner runs a program; a test fakes it. +type Runner interface { + Run(ctx context.Context, name string, args ...string) ([]byte, error) +} + +// KeySource is the machines' public keys as the bus keeps them (ssh-client's state `keys`): the whole current set, +// then each change. handle is told the key, the operation ("put" or "delete"), the value and whether it was there +// when the watch began. +type KeySource interface { + Watch(handle func(key, op string, value []byte, current bool)) error +} + +// Config is what the service needs from its environment. +type Config struct { + // Node is the forge's machine. + Node string + // HostFile holds the forge machine's own mesh name (a fact of the machine), OriginsFile the forge's public + // origins, one per line. + HostFile, OriginsFile string + // Port is where the forge's sshd is published on the mesh. + Port int + Forge forgehttp.Client + Runner Runner + Log func(format string, a ...any) +} + +// Service answers the consumers of the provision `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955): for each +// machine whose ssh-client reported a public key it makes the machine's agent a forge account, registers the key +// for it, and serves where and as whom to reach the forge over ssh. A key is no secret: the offer has no credential. +type Service struct { + cfg Config + accounts *forgeaccount.Accounts + + // OnKeys is told the node whose key changed, so its consumer is answered without waiting for the next pass. + OnKeys func(node string) + // Pause waits between asks of a watch the runtime does not answer yet. + Pause func(time.Duration) + + mu sync.Mutex + keys map[string]machineKey + known string // the forge's host key line, as the machines write it into known_hosts + + making sync.Mutex // one Make at a time: they register keys, and two at once would race each other +} + +type machineKey struct { + Node, Account, Kind, Public string +} + +// NewService is the service over its environment. +func NewService(cfg Config) *Service { + if cfg.Log == nil { + cfg.Log = func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[gitea forge-account] "+f+"\n", a...) } + } + return &Service{cfg: cfg, keys: map[string]machineKey{}, Pause: time.Sleep, + accounts: &forgeaccount.Accounts{Forge: cfg.Forge, Log: cfg.Log}} +} + +// Provider is the provider helper answering the consumers over bus. Every consumer is made again every ten minutes, +// which puts back an account, its team or a key changed by hand. +func (s *Service) Provider(bus provider.Bus) *provider.Provider { + return provider.New(provider.Options{ + Node: s.cfg.Node, Module: Module, + Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.NoCredential}}, + Bus: &valuesBus{Bus: bus, s: s, written: map[string][]byte{}}, + Make: s.Make, Gone: s.Gone, + Every: 10 * time.Minute, + Logf: s.cfg.Log, + }) +} + +// errNoKey is a consumer whose machine has reported no key yet: nothing is made, and the client says it waits. +type errNoKey struct{ node string } + +func (e errNoKey) Error() string { + return "no key from " + e.node + " yet: its ssh-client has not reported a machine key, so there is no account " + + "and no entry to make" +} + +// Make brings one consumer's account, its team membership and its keys into being, as the machine reported them. It +// leaves what is as asked alone, so it is asked again freely. +func (s *Service) Make(ctx context.Context, c provider.Consumer) error { + s.making.Lock() + defer s.making.Unlock() + node := c.Entry.Consumer + user := forgeaccount.ForgeUser(node, "machine") + if user == "" { + return fmt.Errorf("%q is not a machine's name an account can be named for", node) + } + have := s.keysOf(node) + if len(have) == 0 { + return errNoKey{node} + } + if _, err := s.hostName(); err != nil { + return err + } + known, err := s.scan(ctx) + if err != nil { + return err + } + if err := s.accounts.EnsureAccount(ctx, user); err != nil { + return err + } + if err := s.accounts.EnsureInTeam(ctx, user); err != nil { + return err + } + var wanted []forgeaccount.WantedKey + for _, k := range have { + wanted = append(wanted, forgeaccount.WantedKey{Title: forgeaccount.KeyTitle(k.Node, k.Account), Key: k.Public}) + } + if err := s.accounts.EnsureKeys(ctx, user, wanted); err != nil { + return err + } + s.mu.Lock() + s.known = known + s.mu.Unlock() + return nil +} + +// Gone removes the keys the mesh registered for a machine that no longer binds the forge. The account stays: it may +// hold what the machine's agent wrote, and the keys of the operator's own that were added by hand are not touched +// (ADR 0399 §2). +func (s *Service) Gone(ctx context.Context, e provider.Entry) error { + s.making.Lock() + defer s.making.Unlock() + user := forgeaccount.ForgeUser(e.Consumer, "machine") + if user == "" { + return nil + } + return s.accounts.RemoveKeysTitled(ctx, user, forgeaccount.TitlePrefix+e.Consumer+".") +} + +// KeyChanged takes in one machine key as the bus keeps it, and tells OnKeys its node. +func (s *Service) KeyChanged(key string, value []byte) { + if node := s.store(key, value); node != "" && s.OnKeys != nil { + s.OnKeys(node) + } +} + +// KeyGone forgets one machine key, and tells OnKeys its node. +func (s *Service) KeyGone(key string) { + node, _, _ := strings.Cut(key, ".") + s.mu.Lock() + delete(s.keys, key) + s.mu.Unlock() + if s.OnKeys != nil { + s.OnKeys(node) + } +} + +// store keeps a machine key that reads as one: its node and account the key's own, and a public half that is a +// public key line and nothing private. It answers the node, or "" for a value it did not keep (and drops what was +// kept under that key, since the key now says something else). +func (s *Service) store(key string, value []byte) string { + node, account, _ := strings.Cut(key, ".") + var k machineKey + var raw struct { + Node, Account, Kind, Public string + } + err := json.Unmarshal(value, &raw) + k = machineKey(raw) + s.mu.Lock() + defer s.mu.Unlock() + if err != nil || k.Node != node || k.Account != account || !publicKeyLine(k.Public) { + if _, had := s.keys[key]; had { + delete(s.keys, key) + } + s.cfg.Log("the key %s is not a machine's public key; it is not registered", key) + return "" + } + s.keys[key] = k + return node +} + +func publicKeyLine(line string) bool { + f := strings.Fields(line) + return len(f) >= 2 && f[0] == "ssh-ed25519" && !strings.Contains(line, "PRIVATE") && !strings.ContainsAny(line, "\r\n") +} + +// keysOf are the machine keys one node reported, by key. +func (s *Service) keysOf(node string) []machineKey { + s.mu.Lock() + defer s.mu.Unlock() + var names []string + for key, k := range s.keys { + if k.Node == node && k.Kind == "machine" && strings.HasPrefix(key, node+".") { + names = append(names, key) + } + } + sort.Strings(names) + out := make([]machineKey, 0, len(names)) + for _, n := range names { + out = append(out, s.keys[n]) + } + return out +} + +// WatchKeys keeps the machines' keys as the bus holds them, asking again until the runtime answers (the channel to it +// opens as the bundle starts, and the state of ssh-client may arrive after this one). A change after the first +// values is told to OnKeys; the first values are not, since one pass answers them all. +func (s *Service) WatchKeys(ctx context.Context, src KeySource) error { + waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second} + for attempt := 0; ; attempt++ { + err := src.Watch(func(key, op string, value []byte, current bool) { + switch { + case op != "put" && current: + case op != "put": + s.KeyGone(key) + case current: + s.store(key, value) + default: + s.KeyChanged(key, value) + } + }) + if err == nil { + return nil + } + pause := time.Minute + if attempt < len(waits) { + pause = waits[attempt] + } + s.cfg.Log("the machines' keys cannot be watched yet (%v); asking again in %s", err, pause) + if ctx.Err() != nil { + return ctx.Err() + } + s.Pause(pause) + } +} + +// hostName is the forge machine's own mesh name, from the fact the mesh writes. +func (s *Service) hostName() (string, error) { + raw, err := os.ReadFile(s.cfg.HostFile) + if err != nil { + return "", fmt.Errorf("the forge machine's mesh name cannot be read (%s): %w", s.cfg.HostFile, err) + } + host := strings.TrimSpace(string(raw)) + if host == "" || strings.ContainsAny(host, " \t\n[]:/") { + return "", fmt.Errorf("%s holds %q, which is not a machine's mesh name", s.cfg.HostFile, host) + } + return host, nil +} + +// httpsBase is the forge's public https root with a trailing slash. +func (s *Service) httpsBase() (string, error) { + raw, err := os.ReadFile(s.cfg.OriginsFile) + if err != nil { + return "", fmt.Errorf("the forge's origins cannot be read (%s): %w", s.cfg.OriginsFile, err) + } + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimRight(strings.TrimSpace(line), "/") + if strings.HasPrefix(line, "https://") { + return line + "/", nil + } + } + return "", fmt.Errorf("%s names no https origin", s.cfg.OriginsFile) +} + +// scan reads the forge's ed25519 host key from its own sshd and writes it as the machines' known_hosts line. +func (s *Service) scan(ctx context.Context) (string, error) { + host, err := s.hostName() + if err != nil { + return "", err + } + out, err := s.cfg.Runner.Run(ctx, "ssh-keyscan", "-t", "ed25519", "-p", fmt.Sprint(s.cfg.Port), "127.0.0.1") + if err != nil { + return "", fmt.Errorf("the forge's ssh host key cannot be read: %w", err) + } + return knownHostLine(string(out), host, s.cfg.Port) +} + +// knownHostLine is the ed25519 line of ssh-keyscan's output with its host written as the mesh name and port. +func knownHostLine(scanned, host string, port int) (string, error) { + for _, line := range strings.Split(scanned, "\n") { + f := strings.Fields(line) + if len(f) >= 3 && !strings.HasPrefix(f[0], "#") && f[1] == "ssh-ed25519" { + return fmt.Sprintf("[%s]:%d %s %s", host, port, f[1], f[2]), nil + } + } + return "", errors.New("the forge's sshd gave no ed25519 host key") +} + +// valuesFor are the values served to one machine's consumer (ADR 0400 §3, §4). +func (s *Service) valuesFor(node string) (map[string]any, error) { + user := forgeaccount.ForgeUser(node, "machine") + host, err := s.hostName() + if err != nil { + return nil, err + } + https, err := s.httpsBase() + if err != nil { + return nil, err + } + s.mu.Lock() + known := s.known + s.mu.Unlock() + if user == "" || known == "" { + return nil, fmt.Errorf("nothing is known to serve %s yet", node) + } + return map[string]any{"user": user, "ssh_host": host, "ssh_port": s.cfg.Port, "known_host": known, + "ssh_base": fmt.Sprintf("ssh://git@%s:%d/", host, s.cfg.Port), "https_base": https}, nil +} + +// valuesBus writes each consumer's entry with the values that differ per consumer or come from the machine's own +// facts. The provider helper's offer states what is the same for all; the helper has no place for the rest. +type valuesBus struct { + provider.Bus + s *Service + + mu sync.Mutex + written map[string][]byte +} + +func (b *valuesBus) Put(ctx context.Context, bucket, key string, value []byte) error { + if bucket != provider.BucketOf(Module) { + return b.Bus.Put(ctx, bucket, key, value) + } + var e provider.Entry + if err := json.Unmarshal(value, &e); err != nil { + return err + } + if e.Refused == "" { + extra, err := b.s.valuesFor(e.Consumer) + if err != nil { + return err + } + if e.Values == nil { + e.Values = map[string]any{} + } + for k, v := range extra { + e.Values[k] = v + } + } + out, err := json.Marshal(e) + if err != nil { + return err + } + b.mu.Lock() + same := bytes.Equal(b.written[key], out) + b.mu.Unlock() + if same { + return nil // the helper writes what it composed on every pass; this one stands + } + if err := b.Bus.Put(ctx, bucket, key, out); err != nil { + return err + } + b.mu.Lock() + b.written[key] = out + b.mu.Unlock() + return nil +} + +func (b *valuesBus) Delete(ctx context.Context, bucket, key string) error { + b.mu.Lock() + delete(b.written, key) + b.mu.Unlock() + return b.Bus.Delete(ctx, bucket, key) +} diff --git a/modules/gitea/cmd/forge-account-provider/service_test.go b/modules/gitea/cmd/forge-account-provider/service_test.go new file mode 100644 index 00000000..fdfdf949 --- /dev/null +++ b/modules/gitea/cmd/forge-account-provider/service_test.go @@ -0,0 +1,417 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" + + "gitea/internal/forgehttp" +) + +const ( + pubA = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + pubB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" +) + +// fakeBus is the bus as the provider reads and writes it. +type fakeBus struct { + mu sync.Mutex + buckets map[string]map[string][]byte +} + +func newBus() *fakeBus { return &fakeBus{buckets: map[string]map[string][]byte{}} } + +func (b *fakeBus) set(bucket, key string, v []byte) { + b.mu.Lock() + defer b.mu.Unlock() + if b.buckets[bucket] == nil { + b.buckets[bucket] = map[string][]byte{} + } + b.buckets[bucket][key] = v +} +func (b *fakeBus) Watch(_ context.Context, bucket string, h func(string, []byte)) error { + b.mu.Lock() + cur := map[string][]byte{} + for k, v := range b.buckets[bucket] { + cur[k] = v + } + b.mu.Unlock() + for k, v := range cur { + h(k, v) + } + return nil +} +func (b *fakeBus) Put(_ context.Context, bucket, key string, v []byte) error { + b.set(bucket, key, v) + return nil +} +func (b *fakeBus) Delete(_ context.Context, bucket, key string) error { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.buckets[bucket], key) + return nil +} +func (b *fakeBus) entry(t *testing.T, key string) provider.Entry { + t.Helper() + b.mu.Lock() + raw := b.buckets[provider.BucketOf("gitea")][key] + b.mu.Unlock() + if raw == nil { + t.Fatalf("no entry %s", key) + } + var e provider.Entry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + return e +} +func (b *fakeBus) has(key string) bool { + b.mu.Lock() + defer b.mu.Unlock() + _, ok := b.buckets[provider.BucketOf("gitea")][key] + return ok +} + +// bindClient makes ssh-client on node bind gitea's forge-account in the assignments mirror. +func (b *fakeBus) bindClient(node string) { + b.set(provider.AssignmentsBucket, node+".ssh-client", []byte(fmt.Sprintf( + `{"node":%q,"module":"ssh-client","provisioning":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":"mesh_%s_ssh_client"}]}}`, + node, node))) +} + +// fakeForge is gitea's API as far as the provider uses it. +type fakeForge struct { + mu sync.Mutex + users map[string]bool + teams map[string]bool + members []string + held map[string][]map[string]any + next int64 +} + +func newForge() *fakeForge { + return &fakeForge{users: map[string]bool{}, held: map[string][]map[string]any{}} +} + +var keyRe = regexp.MustCompile(`^/api/v1/(?:admin/)?users/([^/]+)/keys(?:/(\d+))?$`) + +func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + body, _ := io.ReadAll(r.Body) + var in map[string]any + _ = json.Unmarshal(body, &in) + reply := func(code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if v != nil { + _ = json.NewEncoder(w).Encode(v) + } + } + p := r.URL.Path + if m := keyRe.FindStringSubmatch(p); m != nil { + switch r.Method { + case http.MethodGet: + reply(200, f.held[m[1]]) + case http.MethodPost: + f.next++ + f.held[m[1]] = append(f.held[m[1]], map[string]any{"id": f.next, "title": in["title"], "key": in["key"]}) + reply(201, map[string]any{"id": f.next}) + case http.MethodDelete: + var kept []map[string]any + for _, k := range f.held[m[1]] { + if fmt.Sprint(k["id"]) != m[2] { + kept = append(kept, k) + } + } + f.held[m[1]] = kept + reply(204, nil) + } + return + } + switch { + case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/users/"): + if !f.users[strings.TrimPrefix(p, "/api/v1/users/")] { + reply(404, map[string]any{"message": "no"}) + return + } + reply(200, map[string]any{"active": true}) + case r.Method == http.MethodPost && p == "/api/v1/admin/users": + f.users[in["username"].(string)] = true + reply(201, map[string]any{}) + case p == "/api/v1/orgs/novox/teams" && r.Method == http.MethodGet: + reply(200, []map[string]any{{"id": 5, "name": "agents"}}) + case p == "/api/v1/teams/5" && r.Method == http.MethodPatch: + reply(200, map[string]any{}) + case strings.HasPrefix(p, "/api/v1/teams/5/members/") && r.Method == http.MethodPut: + f.members = append(f.members, strings.TrimPrefix(p, "/api/v1/teams/5/members/")) + reply(204, nil) + default: + http.Error(w, "unexpected "+r.Method+" "+p, 400) + } +} + +func (f *fakeForge) titles(user string) []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, k := range f.held[user] { + out = append(out, k["title"].(string)) + } + return out +} + +type fakeRunner struct { + out string + err error + args []string +} + +func (r *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + r.args = append([]string{name}, args...) + return []byte(r.out), r.err +} + +const scanned = "# 127.0.0.1:222 SSH-2.0-Go\n[127.0.0.1]:222 ssh-ed25519 AAAAC3HOSTKEY\n" + +type rig struct { + t *testing.T + bus *fakeBus + forge *fakeForge + svc *Service + runner *fakeRunner +} + +func newRig(t *testing.T) *rig { + t.Helper() + forge := newForge() + srv := httptest.NewServer(forge) + t.Cleanup(srv.Close) + dir := t.TempDir() + pw := filepath.Join(dir, "pw") + host := filepath.Join(dir, "host") + origins := filepath.Join(dir, "origins") + for p, c := range map[string]string{pw: "x\n", host: "novox.internal\n", origins: "https://git.novox.be\n"} { + if err := os.WriteFile(p, []byte(c), 0o600); err != nil { + t.Fatal(err) + } + } + runner := &fakeRunner{out: scanned} + svc := NewService(Config{Node: "novox", HostFile: host, OriginsFile: origins, Port: 222, + Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Runner: runner, + Log: func(string, ...any) {}}) + return &rig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner} +} + +func (r *rig) pass() []provider.Outcome { + r.t.Helper() + p := r.svc.Provider(r.bus) + out, err := p.Pass(context.Background()) + if err != nil { + r.t.Fatal(err) + } + return out +} + +func (r *rig) key(node, account, public string) { + b, _ := json.Marshal(map[string]string{"node": node, "account": account, "kind": "machine", "public": public, + "fingerprint": "SHA256:x", "at": "2026-10-16T10:00:00Z"}) + r.svc.KeyChanged(node+"."+account, b) +} + +func TestAConsumerWithAKeyGetsItsEntryAndItsKeyIsRegistered(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + r.key("g14", "nox", pubA+" nox@g14 (mesh)") + r.pass() + e := r.bus.entry(t, "g14.ssh-client.forge-account") + want := map[string]any{"user": "agent-g14", "ssh_host": "novox.internal", "ssh_port": float64(222), + "known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY", "ssh_base": "ssh://git@novox.internal:222/", + "https_base": "https://git.novox.be/"} + for k, v := range want { + if e.Values[k] != v { + t.Errorf("%s = %v, want %v", k, e.Values[k], v) + } + } + if e.VaultName != "" || e.User != "" { + t.Errorf("a public key is no secret; the entry names a credential: %+v", e) + } + if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "mesh:g14.nox" { + t.Errorf("registered keys = %v", got) + } + if !r.forge.users["agent-g14"] || len(r.forge.members) != 1 || r.forge.members[0] != "agent-g14" { + t.Errorf("account %v, team members %v", r.forge.users, r.forge.members) + } + if strings.Join(r.runner.args, " ") != "ssh-keyscan -t ed25519 -p 222 127.0.0.1" { + t.Errorf("scan = %v", r.runner.args) + } +} + +func TestAConsumerWithoutAKeyGetsNoEntryAndNoAccount(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + r.key("ace", "nox", pubB) // another machine's key is not g14's + out := r.pass() + if r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("an entry was written for a machine that reported no key") + } + if len(r.forge.users) != 0 { + t.Fatalf("an account was made: %v", r.forge.users) + } + if len(out) != 1 || !strings.Contains(out[0].Why, "no key") { + t.Fatalf("the wait should be said: %+v", out) + } +} + +func TestAKeyOfAnotherKindIsNotTheMachinesKey(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + b, _ := json.Marshal(map[string]string{"node": "g14", "account": "x", "kind": "session", "public": pubA}) + r.svc.KeyChanged("g14.x", b) + r.pass() + if r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("answered with a key that is not a machine's") + } +} + +func TestAChangedKeyReplacesTheOldMeshKey(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + r.key("g14", "nox", pubA+" nox@g14 (mesh)") + r.pass() + r.forge.mu.Lock() + r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB}) + r.forge.mu.Unlock() + r.key("g14", "nox", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC nox@g14 (mesh)") + r.pass() + got := strings.Join(r.forge.titles("agent-g14"), ",") + if got != "my own,mesh:g14.nox" { + t.Fatalf("keys = %s", got) + } + for _, k := range r.forge.held["agent-g14"] { + if k["title"] == "mesh:g14.nox" && !strings.Contains(k["key"].(string), "CCCC") { + t.Fatalf("the old key stayed: %v", k) + } + } +} + +func TestAConsumerThatNoLongerBindsLosesItsMeshKeysAndKeepsItsAccount(t *testing.T) { + r := newRig(t) + r.bus.bindClient("g14") + r.key("g14", "nox", pubA+" nox@g14 (mesh)") + r.pass() + r.forge.mu.Lock() + r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB}) + r.forge.mu.Unlock() + r.bus.mu.Lock() + delete(r.bus.buckets[provider.AssignmentsBucket], "g14.ssh-client") + r.bus.mu.Unlock() + r.pass() + if r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("the entry stayed") + } + if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "my own" { + t.Fatalf("keys = %v", got) + } + if !r.forge.users["agent-g14"] { + t.Fatal("the account was removed") + } +} + +func TestAKeyChangeNamesTheConsumersToAnswerAgain(t *testing.T) { + r := newRig(t) + var got []string + r.svc.OnKeys = func(node string) { got = append(got, node) } + r.key("g14", "nox", pubA) + if len(got) != 1 || got[0] != "g14" { + t.Fatalf("answered for %v", got) + } +} + +func TestAKeyThatIsNotReadableIsNotKept(t *testing.T) { + r := newRig(t) + r.svc.KeyChanged("g14.nox", []byte(`{"node":"g14","kind":"machine"`)) + r.svc.KeyChanged("g14.nox", []byte(`{"node":"g14","account":"nox","kind":"machine","public":"PRIVATE KEY"}`)) + r.bus.bindClient("g14") + r.pass() + if r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("a value that is no public key was registered") + } +} + +func TestTheForgesHostKeyUnreadableMeansNoEntry(t *testing.T) { + r := newRig(t) + r.runner.err = errors.New("connection refused") + r.bus.bindClient("g14") + r.key("g14", "nox", pubA) + r.pass() + if r.bus.has("g14.ssh-client.forge-account") { + t.Fatal("an entry without the forge's host key") + } +} + +func TestKnownHostLineIsRewrittenToTheMeshName(t *testing.T) { + line, err := knownHostLine(scanned, "novox.internal", 222) + if err != nil || line != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" { + t.Fatalf("%q %v", line, err) + } + if _, err := knownHostLine("# nothing\n", "novox.internal", 222); err == nil { + t.Fatal("no key line was accepted") + } +} + +func TestKeysWatchedRetriesUntilItAnswersAndPassesOnChanges(t *testing.T) { + r := newRig(t) + src := &fakeKeys{failFirst: 2} + r.svc.Pause = func(time.Duration) {} + passes := 0 + r.svc.OnKeys = func(string) { passes++ } + if err := r.svc.WatchKeys(context.Background(), src); err != nil { + t.Fatal(err) + } + if src.calls != 3 { + t.Fatalf("asked %d times", src.calls) + } + src.send("g14.nox", "put", []byte(`{"node":"g14","account":"nox","kind":"machine","public":"`+pubA+`"}`), false) + if passes != 1 { + t.Fatalf("passes = %d", passes) + } + // What was there when the watch began is taken in, and one pass answers it, not one per key. + src.send("ace.nox", "put", []byte(`{"node":"ace","account":"nox","kind":"machine","public":"`+pubB+`"}`), true) + if passes != 1 { + t.Fatalf("a current value passed on its own: %d", passes) + } + src.send("g14.nox", "delete", nil, false) + if passes != 2 || len(r.svc.keysOf("g14")) != 0 { + t.Fatalf("passes = %d, keys = %v", passes, r.svc.keysOf("g14")) + } +} + +type fakeKeys struct { + failFirst int + calls int + handle func(key, op string, value []byte, current bool) +} + +func (f *fakeKeys) Watch(handle func(key, op string, value []byte, current bool)) error { + f.calls++ + if f.calls <= f.failFirst { + return errors.New("not yet") + } + f.handle = handle + return nil +} +func (f *fakeKeys) send(key, op string, v []byte, current bool) { f.handle(key, op, v, current) } diff --git a/modules/gitea/go.mod b/modules/gitea/go.mod index ea8089d6..0fa6eee9 100644 --- a/modules/gitea/go.mod +++ b/modules/gitea/go.mod @@ -2,7 +2,7 @@ module gitea go 1.25.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 require ( golang.org/x/crypto v0.50.0 diff --git a/modules/gitea/go.sum b/modules/gitea/go.sum index afe914a8..735d7c6c 100644 --- a/modules/gitea/go.sum +++ b/modules/gitea/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= diff --git a/modules/gitea/internal/forgeaccount/account.go b/modules/gitea/internal/forgeaccount/account.go index 57ec5776..6c36381b 100644 --- a/modules/gitea/internal/forgeaccount/account.go +++ b/modules/gitea/internal/forgeaccount/account.go @@ -12,6 +12,7 @@ import ( "fmt" "net/http" "os" + "regexp" "gitea/internal/forgehttp" ) @@ -143,3 +144,15 @@ func (m *Accounts) say(format string, a ...any) { } fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// NodeName is what a machine's name is, for it to name an account. +var NodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) + +// ForgeUser is the forge user of a machine's key of the given kind (ADR 0400 §3): `agent-` for the machine's +// own account (kind machine). A kind not built yet has none, and the answer is empty. +func ForgeUser(node, kind string) string { + if kind == "machine" && NodeName.MatchString(node) { + return AccountPrefix + node + } + return "" +} diff --git a/modules/gitea/internal/forgeaccount/keys.go b/modules/gitea/internal/forgeaccount/keys.go index 018241b9..2f5a43f6 100644 --- a/modules/gitea/internal/forgeaccount/keys.go +++ b/modules/gitea/internal/forgeaccount/keys.go @@ -130,3 +130,26 @@ func (m *Accounts) EnsureKeys(ctx context.Context, user string, wanted []WantedK } return nil } + +// RemoveKeysTitled removes the account's keys whose title starts with prefix, and only a prefix the mesh gave +// (starting `mesh:`, such as `mesh:g14.`): what the mesh registered for one machine, never a key of the operator's +// (ADR 0399 §2). +func (m *Accounts) RemoveKeysTitled(ctx context.Context, user, prefix string) error { + if !strings.HasPrefix(prefix, TitlePrefix) { + return nil + } + held, err := m.Keys(ctx, user) + if err != nil { + return err + } + for _, k := range held { + if !strings.HasPrefix(k.Title, prefix) { + continue + } + if err := m.DeleteKey(ctx, user, k.ID); err != nil { + return err + } + m.say("removed the key %s of %s: its machine no longer binds the forge", k.Title, user) + } + return nil +} diff --git a/modules/gitea/internal/forgeaccount/user_test.go b/modules/gitea/internal/forgeaccount/user_test.go new file mode 100644 index 00000000..82875058 --- /dev/null +++ b/modules/gitea/internal/forgeaccount/user_test.go @@ -0,0 +1,33 @@ +package forgeaccount + +import "testing" + +func TestForgeUserOfAMachineAccountIsTheNodesAgent(t *testing.T) { + if got := ForgeUser("g14", "machine"); got != "agent-g14" { + t.Fatalf("ForgeUser = %q", got) + } + if got := ForgeUser("g14", "other"); got != "" { + t.Fatalf("a kind not built yet has no forge user, got %q", got) + } +} + +func TestRemoveKeysTitledTakesOnlyTheMeshKeysOfThatTitle(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{"agent-g14": { + {ID: 1, Title: "mesh:g14.nox", Key: keyA}, + {ID: 2, Title: "mesh:g14.other", Key: keyB}, + {ID: 3, Title: "by hand", Key: "ssh-ed25519 CCCC"}, + }}, next: 3} + a := keyAccounts(t, f) + if err := a.RemoveKeysTitled(t.Context(), "agent-g14", "mesh:g14.nox"); err != nil { + t.Fatal(err) + } + if got := f.keys["agent-g14"]; len(got) != 2 || got[0].ID != 2 || got[1].ID != 3 { + t.Fatalf("held = %v", got) + } + if err := a.RemoveKeysTitled(t.Context(), "agent-g14", "by hand"); err != nil { + t.Fatal(err) + } + if len(f.keys["agent-g14"]) != 2 { + t.Fatal("a key that is not the mesh's was removed") + } +}