package forgeaccount import ( "context" "errors" "fmt" "net/http" "strings" "gitea/internal/forgehttp" ) // TitlePrefix starts the title of every key the mesh registers; a key without it is the operator's and is never // touched (ADR 0399 §2, ADR 0400). const TitlePrefix = "mesh:" // KeyTitle is the title of a machine account's key: `mesh:.`. func KeyTitle(node, account string) string { return TitlePrefix + node + "." + account } // WantedKey is a public key the mesh wants registered, under its title. type WantedKey struct { Title string // Key is the public key line: type, base64 and a comment. Key string } // Key is a key the forge holds for an account. type Key struct { ID int64 `json:"id"` Title string `json:"title"` Key string `json:"key"` } // material is a public key line without its comment: its type and its base64, which is what makes it the same key. func material(line string) string { f := strings.Fields(line) if len(f) < 2 { return strings.TrimSpace(line) } return f[0] + " " + f[1] } // Keys lists the SSH keys the account holds. func (m *Accounts) Keys(ctx context.Context, user string) ([]Key, error) { var all []Key for page := 1; page <= 20; page++ { var got []Key if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/users/%s/keys?limit=50&page=%d", user, page), &got); err != nil { return nil, fmt.Errorf("the keys of %s could not be read: %w", user, err) } all = append(all, got...) if len(got) < 50 { break } } return all, nil } // AddKey registers a key for the account, one it may write with. func (m *Accounts) AddKey(ctx context.Context, user string, k WantedKey) error { err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users/"+user+"/keys", map[string]any{"title": k.Title, "key": k.Key, "read_only": false}, nil) if err != nil { return fmt.Errorf("the key %s could not be added for %s: %w", k.Title, user, err) } return nil } // DeleteKey removes one key of the account, by its id. func (m *Accounts) DeleteKey(ctx context.Context, user string, id int64) error { err := m.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/admin/users/%s/keys/%d", user, id), nil, nil) if err != nil && !errors.Is(err, forgehttp.ErrNotFound) { return fmt.Errorf("the key %d of %s could not be deleted: %w", id, user, err) } return nil } // EnsureKeys registers each wanted key the account does not hold and removes the keys the mesh registered // (title starting `mesh:`) that are no longer wanted. A key whose title does not start `mesh:` is the operator's and // is never touched, and a wanted key the account already holds under another title is left as it is (ADR 0399 §2). // A key is added before another title's is removed, so an account is not left without one. func (m *Accounts) EnsureKeys(ctx context.Context, user string, wanted []WantedKey) error { held, err := m.Keys(ctx, user) if err != nil { return err } have := map[string]bool{} for _, k := range held { have[material(k.Key)] = true } want := map[string]bool{} for _, w := range wanted { want[material(w.Key)] = true } gone := map[int64]bool{} remove := func(k Key) error { if gone[k.ID] { return nil } gone[k.ID] = true if err := m.DeleteKey(ctx, user, k.ID); err != nil { return err } m.say("removed the key %s of %s: it is no longer wanted", k.Title, user) return nil } for _, w := range wanted { if have[material(w.Key)] { continue } // The forge holds a title once: the mesh's key of the same title, now another key, goes first. for _, k := range held { if k.Title == w.Title && strings.HasPrefix(k.Title, TitlePrefix) && !want[material(k.Key)] { if err := remove(k); err != nil { return err } } } if err := m.AddKey(ctx, user, w); err != nil { return err } m.say("registered the key %s for %s", w.Title, user) } for _, k := range held { if strings.HasPrefix(k.Title, TitlePrefix) && !want[material(k.Key)] { if err := remove(k); err != nil { return err } } } return nil } // 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 }