Add the forge-account provider: an agent account, its team and its machine key per consumer (issue 955 F3)

This commit is contained in:
2026-10-16 22:45:51 +02:00
parent 8131c13634
commit c346bba34e
10 changed files with 1022 additions and 6 deletions
+2 -3
View File
@@ -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
}
@@ -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,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)
}
@@ -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) }
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
@@ -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-<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 ""
}
@@ -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
}
@@ -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")
}
}