349 lines
12 KiB
Go
349 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/nacl/box"
|
|
|
|
"gitea/internal/forgehttp"
|
|
)
|
|
|
|
const (
|
|
// ToolName is the issuing tool the vault asks (the offer's credential tool in module.json).
|
|
ToolName = "gitea_agent_token"
|
|
// Provision is what the tool issues: the forge's read credential, one per agent.
|
|
Provision = "gitea-read"
|
|
// Holder is the one consuming module answered: the agent's own module, which places the credential in the
|
|
// agent's home (ADR 0365 §4, ADR 0266). A holder of another module is not answered and keeps nothing.
|
|
Holder = "claude-code"
|
|
// TokenName is the token's name in the agent account's list, found and deleted by it at the next mint.
|
|
TokenName = "mesh-agent-read"
|
|
// AccountPrefix names an agent's account for its node (ADR 0365 §1).
|
|
AccountPrefix = "agent-"
|
|
// vaultModule is the vault's module: its account is `<node>.mesh-vault` (ADR 0259 §8's runs-as).
|
|
vaultModule = "mesh-vault"
|
|
// deadline bounds one call, inside the vault's ask.
|
|
deadline = 45 * time.Second
|
|
)
|
|
|
|
// TokenScopes are the only scopes an agent's token carries (ADR 0365 §4, §5): it reads repositories and nothing
|
|
// else, so the forge refuses it a push, a comment, a token of its own and any administration — whatever team the
|
|
// account is in.
|
|
var TokenScopes = []string{"read:repository"}
|
|
|
|
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
|
|
// tool would hold every agent's credential.
|
|
func fromTheVault(caller string) error {
|
|
node, module, ok := strings.Cut(caller, ".")
|
|
if ok && module == vaultModule && nodeName.MatchString(node) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s answers the vault alone (novox/hq ADR 0349 clause 10, ADR 0365); %q is not the vault, and "+
|
|
"nothing was minted", ToolName, caller)
|
|
}
|
|
|
|
// HolderAsk is one holder the vault named.
|
|
type HolderAsk struct {
|
|
Node string `json:"node"`
|
|
Module string `json:"module"`
|
|
}
|
|
|
|
// Ask is one call: the holders, and the key to seal each answer to.
|
|
type Ask struct {
|
|
SealTo [32]byte
|
|
Holders []HolderAsk
|
|
}
|
|
|
|
// readAsk reads the call's arguments, refusing anything that is not the vault's per-holder ask.
|
|
func readAsk(args map[string]any) (Ask, error) {
|
|
if p, _ := args["provision"].(string); p != Provision {
|
|
return Ask{}, fmt.Errorf("%s issues %s alone, not %q; nothing was minted", ToolName, Provision, p)
|
|
}
|
|
key, _ := args["seal_to"].(string)
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(key))
|
|
if err != nil || len(raw) != 32 {
|
|
return Ask{}, errors.New("seal_to is not the key of a call; nothing was minted")
|
|
}
|
|
var out Ask
|
|
copy(out.SealTo[:], raw)
|
|
list, ok := args["holders"].([]any)
|
|
if !ok || len(list) == 0 {
|
|
return Ask{}, fmt.Errorf("%s answers a value for each holder, and none was named; nothing was minted", ToolName)
|
|
}
|
|
seen := map[HolderAsk]bool{}
|
|
for _, item := range list {
|
|
m, _ := item.(map[string]any)
|
|
node, _ := m["node"].(string)
|
|
module, _ := m["module"].(string)
|
|
h := HolderAsk{Node: strings.TrimSpace(node), Module: strings.TrimSpace(module)}
|
|
if h.Node == "" || h.Module == "" || seen[h] {
|
|
return Ask{}, errors.New("each holder is a node and a module, named once; nothing was minted")
|
|
}
|
|
seen[h] = true
|
|
out.Holders = append(out.Holders, h)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Minter mints the agents' tokens with the administrator's login, which sets the forge up and nothing else (ADR
|
|
// 0365 §2).
|
|
type Minter struct {
|
|
Forge forgehttp.Client
|
|
// Node is the forge's own machine: a holder there also reaches the forge at Forge.URL.
|
|
Node string
|
|
// OriginsFile holds the forge's public origins, one per line (https://<its route's name>): the origins every
|
|
// holder's credential is for.
|
|
OriginsFile string
|
|
// Password makes the password an agent's account is created with: random, sent once and kept nowhere.
|
|
Password func() string
|
|
Log func(format string, a ...any)
|
|
}
|
|
|
|
// MinterFromEnv is the minter as the manifest hands it to this bundle. It builds the administrator's client itself:
|
|
// the module's words reach all its bundles, and a word naming another bundle's token must not make this one act as
|
|
// another account.
|
|
func MinterFromEnv() (*Minter, error) {
|
|
m := &Minter{
|
|
Forge: forgehttp.Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"),
|
|
User: os.Getenv("MESH_GITEA_ADMIN_USER"), PasswordFile: os.Getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"),
|
|
Timeout: 20 * time.Second},
|
|
Node: os.Getenv("MESH_NODE"),
|
|
OriginsFile: os.Getenv("MESH_GITEA_ORIGINS_FILE"),
|
|
}
|
|
var missing []string
|
|
for _, kv := range [][2]string{{"MESH_GITEA_URL", m.Forge.URL}, {"MESH_GITEA_ADMIN_USER", m.Forge.User},
|
|
{"MESH_GITEA_ADMIN_PASSWORD_FILE", m.Forge.PasswordFile}, {"MESH_GITEA_ORIGINS_FILE", m.OriginsFile}} {
|
|
if kv[1] == "" {
|
|
missing = append(missing, kv[0])
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return nil, fmt.Errorf("the forge cannot be reached: %s not set by the manifest; nothing was minted", strings.Join(missing, ", "))
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// Sealed is one holder's credential as the vault takes it.
|
|
type Sealed struct {
|
|
Node string `json:"node"`
|
|
Module string `json:"module"`
|
|
Sealed string `json:"sealed"`
|
|
}
|
|
|
|
// Answer is what the tool answers: a sealed credential for each holder answered, and why each other was not. Never
|
|
// a token.
|
|
type Answer struct {
|
|
Holders []Sealed `json:"holders"`
|
|
Skipped map[string]string `json:"skipped,omitempty"`
|
|
}
|
|
|
|
// one call at a time: a mint deletes the account's token from before, and two at once would delete each other's.
|
|
var minting sync.Mutex
|
|
|
|
// Answer mints each holder's token and seals its credential lines. A holder that is not an agent's module is not
|
|
// answered; a failure for one holder fails the call, so the vault keeps nothing rather than a part.
|
|
func (m *Minter) Answer(ask Ask) (Answer, error) {
|
|
minting.Lock()
|
|
defer minting.Unlock()
|
|
ctx, cancel := context.WithTimeout(context.Background(), deadline)
|
|
defer cancel()
|
|
origins, err := m.origins()
|
|
if err != nil {
|
|
return Answer{}, err
|
|
}
|
|
out := Answer{Holders: []Sealed{}}
|
|
skip := func(h HolderAsk, why string) {
|
|
if out.Skipped == nil {
|
|
out.Skipped = map[string]string{}
|
|
}
|
|
out.Skipped[h.Module+" on "+h.Node] = why
|
|
}
|
|
for _, h := range ask.Holders {
|
|
if h.Module != Holder {
|
|
skip(h, fmt.Sprintf("only %s places the forge's read credential, in its agent's home (ADR 0365 §4)", Holder))
|
|
continue
|
|
}
|
|
if !nodeName.MatchString(h.Node) {
|
|
skip(h, "not a machine's name an account can be named for")
|
|
continue
|
|
}
|
|
reach := origins
|
|
if h.Node == m.Node {
|
|
reach = append(append([]string{}, origins...), m.Forge.URL)
|
|
}
|
|
if len(reach) == 0 {
|
|
skip(h, "the forge names no origin it is reached at from that machine")
|
|
continue
|
|
}
|
|
account := AccountPrefix + h.Node
|
|
if err := m.ensureAccount(ctx, account); err != nil {
|
|
return Answer{}, err
|
|
}
|
|
token, err := m.mint(ctx, account)
|
|
if err != nil {
|
|
return Answer{}, err
|
|
}
|
|
lines, err := credentialLines(account, token, reach)
|
|
token = ""
|
|
if err != nil {
|
|
return Answer{}, err
|
|
}
|
|
sealed, err := box.SealAnonymous(nil, []byte(lines), &ask.SealTo, rand.Reader)
|
|
lines = ""
|
|
if err != nil {
|
|
return Answer{}, fmt.Errorf("the credential for %s could not be sealed: %w", account, err)
|
|
}
|
|
out.Holders = append(out.Holders, Sealed{Node: h.Node, Module: h.Module, Sealed: base64.StdEncoding.EncodeToString(sealed)})
|
|
m.say("minted a read token for %s (%s on %s); its token from before is deleted", account, h.Module, h.Node)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// origins are the forge's public origins, as its route names them.
|
|
func (m *Minter) origins() ([]string, error) {
|
|
raw, err := os.ReadFile(m.OriginsFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the forge's origins cannot be read (%s); nothing was minted: %w", m.OriginsFile, err)
|
|
}
|
|
var out []string
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
line = strings.TrimRight(strings.TrimSpace(line), "/")
|
|
if line == "" {
|
|
continue
|
|
}
|
|
u, err := url.Parse(line)
|
|
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.Path != "" {
|
|
return nil, fmt.Errorf("%s names %q, which is not an origin (scheme://host); nothing was minted", m.OriginsFile, line)
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// credentialLines are the lines of git's credential store for one account at each origin: what the agent's module
|
|
// places, and git reads by protocol and host.
|
|
func credentialLines(account, token string, origins []string) (string, error) {
|
|
seen := map[string]bool{}
|
|
var lines []string
|
|
for _, o := range origins {
|
|
u, err := url.Parse(strings.TrimRight(o, "/"))
|
|
if err != nil || u.Host == "" {
|
|
return "", fmt.Errorf("%q is not an origin", o)
|
|
}
|
|
u.Path, u.RawQuery, u.Fragment = "", "", ""
|
|
u.User = url.UserPassword(account, token)
|
|
if line := u.String(); !seen[line] {
|
|
seen[line] = true
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
sort.Strings(lines)
|
|
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. It adds it to no team: what it may read beyond public
|
|
// repositories is the operator's to grant (ADR 0365 §3).
|
|
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", 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
|
|
}
|
|
|
|
type forgeToken struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
SHA1 string `json:"sha1"`
|
|
}
|
|
|
|
// mint deletes every token of the account by this module's name, by its id (a name held twice is cleared, not
|
|
// refused), and mints one with the read scope alone.
|
|
func (m *Minter) mint(ctx context.Context, account string) (string, error) {
|
|
var held []forgeToken
|
|
if err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account+"/tokens?limit=50", &held); err != nil {
|
|
return "", fmt.Errorf("the tokens of %s could not be read: %w", account, err)
|
|
}
|
|
for _, t := range held {
|
|
if t.Name != TokenName {
|
|
continue
|
|
}
|
|
if err := m.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/users/%s/tokens/%d", account, t.ID), nil, nil); err != nil &&
|
|
!errors.Is(err, forgehttp.ErrNotFound) {
|
|
return "", fmt.Errorf("the token of %s from before could not be deleted: %w", account, err)
|
|
}
|
|
}
|
|
var made forgeToken
|
|
if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/users/"+account+"/tokens",
|
|
map[string]any{"name": TokenName, "scopes": TokenScopes}, &made); err != nil {
|
|
return "", fmt.Errorf("no token could be minted for %s: %w", account, err)
|
|
}
|
|
if strings.TrimSpace(made.SHA1) == "" {
|
|
return "", fmt.Errorf("the forge answered no token for %s", account)
|
|
}
|
|
return strings.TrimSpace(made.SHA1), nil
|
|
}
|
|
|
|
func (m *Minter) password() string {
|
|
if m.Password != nil {
|
|
return m.Password()
|
|
}
|
|
b := make([]byte, 24)
|
|
_, _ = rand.Read(b)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func (m *Minter) say(format string, a ...any) {
|
|
if m.Log != nil {
|
|
m.Log(format, a...)
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
|
}
|