Add the forge-account provider: an agent account, its team and its machine key per consumer (issue 955 F3)
This commit is contained in:
@@ -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) }
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user