ScuttleBot

scuttlebot / internal / registry / registry.go
Blame History Raw 529 lines
1
// Package registry manages agent registration and credential lifecycle.
2
//
3
// Agents register with scuttlebot and receive SASL credentials for the Ergo
4
// IRC server, plus a signed rules-of-engagement payload describing their
5
// channel assignments and permissions.
6
package registry
7
8
import (
9
"crypto/hmac"
10
"crypto/rand"
11
"crypto/sha256"
12
"encoding/hex"
13
"encoding/json"
14
"fmt"
15
"os"
16
"strings"
17
"sync"
18
"time"
19
20
"github.com/conflicthq/scuttlebot/internal/store"
21
)
22
23
// AgentType describes an agent's role and authority level.
24
type AgentType string
25
26
const (
27
AgentTypeOperator AgentType = "operator" // human operator — +o + full permissions
28
AgentTypeOrchestrator AgentType = "orchestrator" // +o in channels
29
AgentTypeWorker AgentType = "worker" // +v in channels
30
AgentTypeObserver AgentType = "observer" // no special mode
31
)
32
33
// Agent is a registered agent.
34
type Agent struct {
35
Nick string `json:"nick"`
36
Type AgentType `json:"type"`
37
Channels []string `json:"channels"` // convenience: same as Config.Channels
38
Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
39
Config EngagementConfig `json:"config"`
40
Skills []string `json:"skills,omitempty"` // agent capabilities (e.g. "go", "python", "react")
41
CreatedAt time.Time `json:"created_at"`
42
Revoked bool `json:"revoked"`
43
LastSeen *time.Time `json:"last_seen,omitempty"`
44
Online bool `json:"online"`
45
}
46
47
// Credentials are the SASL credentials an agent uses to connect to Ergo.
48
type Credentials struct {
49
Nick string `json:"nick"`
50
Passphrase string `json:"passphrase"`
51
}
52
53
// EngagementPayload is the signed payload delivered to an agent on registration.
54
// Agents verify this with VerifyPayload() before trusting its contents.
55
type EngagementPayload struct {
56
V int `json:"v"`
57
Nick string `json:"nick"`
58
Type AgentType `json:"type"`
59
Config EngagementConfig `json:"config"`
60
IssuedAt time.Time `json:"issued_at"`
61
}
62
63
// SignedPayload wraps an EngagementPayload with an HMAC signature.
64
type SignedPayload struct {
65
Payload EngagementPayload `json:"payload"`
66
Signature string `json:"signature"` // hex-encoded HMAC-SHA256
67
}
68
69
// AccountProvisioner is the interface the registry uses to create/modify IRC accounts.
70
// Implemented by *ergo.APIClient in production; can be mocked in tests.
71
type AccountProvisioner interface {
72
RegisterAccount(name, passphrase string) error
73
ChangePassword(name, passphrase string) error
74
}
75
76
// Registry manages registered agents and their credentials.
77
type Registry struct {
78
mu sync.RWMutex
79
agents map[string]*Agent // keyed by nick
80
provisioner AccountProvisioner
81
signingKey []byte
82
dataPath string // path to persist agents JSON; empty = no persistence
83
db *store.Store // when non-nil, supersedes dataPath
84
onlineTimeout time.Duration
85
}
86
87
// New creates a new Registry with the given provisioner and HMAC signing key.
88
// Call SetDataPath to enable persistence before registering any agents.
89
func New(provisioner AccountProvisioner, signingKey []byte) *Registry {
90
return &Registry{
91
agents: make(map[string]*Agent),
92
provisioner: provisioner,
93
signingKey: signingKey,
94
}
95
}
96
97
// SetDataPath enables file-based persistence. The registry is loaded from path
98
// immediately (non-fatal if the file doesn't exist yet) and saved there after
99
// every mutation. Mutually exclusive with SetStore.
100
func (r *Registry) SetDataPath(path string) error {
101
r.mu.Lock()
102
defer r.mu.Unlock()
103
r.dataPath = path
104
return r.load()
105
}
106
107
// SetStore switches the registry to database-backed persistence. All current
108
// in-memory state is replaced with rows loaded from the store. Mutually
109
// exclusive with SetDataPath.
110
func (r *Registry) SetStore(db *store.Store) error {
111
rows, err := db.AgentList()
112
if err != nil {
113
return fmt.Errorf("registry: load from store: %w", err)
114
}
115
r.mu.Lock()
116
defer r.mu.Unlock()
117
r.db = db
118
r.dataPath = "" // DB takes over
119
r.agents = make(map[string]*Agent, len(rows))
120
for _, row := range rows {
121
var cfg EngagementConfig
122
if err := json.Unmarshal(row.Config, &cfg); err != nil {
123
return fmt.Errorf("registry: decode agent %s config: %w", row.Nick, err)
124
}
125
a := &Agent{
126
Nick: row.Nick,
127
Type: AgentType(row.Type),
128
Channels: cfg.Channels,
129
Permissions: cfg.Permissions,
130
Config: cfg,
131
CreatedAt: row.CreatedAt,
132
Revoked: row.Revoked,
133
LastSeen: row.LastSeen,
134
}
135
r.agents[a.Nick] = a
136
}
137
return nil
138
}
139
140
// saveOne persists a single agent. Uses the DB when available, otherwise
141
// falls back to a full file rewrite.
142
func (r *Registry) saveOne(a *Agent) {
143
if r.db != nil {
144
cfg, _ := json.Marshal(a.Config)
145
_ = r.db.AgentUpsert(&store.AgentRow{
146
Nick: a.Nick,
147
Type: string(a.Type),
148
Config: cfg,
149
CreatedAt: a.CreatedAt,
150
Revoked: a.Revoked,
151
LastSeen: a.LastSeen,
152
})
153
return
154
}
155
r.save()
156
}
157
158
// deleteOne removes a single agent from the store. Uses the DB when available,
159
// otherwise falls back to a full file rewrite (agent already removed from map).
160
func (r *Registry) deleteOne(nick string) {
161
if r.db != nil {
162
_ = r.db.AgentDelete(nick)
163
return
164
}
165
r.save()
166
}
167
168
func (r *Registry) load() error {
169
data, err := os.ReadFile(r.dataPath)
170
if os.IsNotExist(err) {
171
return nil
172
}
173
if err != nil {
174
return fmt.Errorf("registry: load: %w", err)
175
}
176
var agents []*Agent
177
if err := json.Unmarshal(data, &agents); err != nil {
178
return fmt.Errorf("registry: load: %w", err)
179
}
180
for _, a := range agents {
181
r.agents[a.Nick] = a
182
}
183
return nil
184
}
185
186
func (r *Registry) save() {
187
if r.dataPath == "" {
188
return
189
}
190
agents := make([]*Agent, 0, len(r.agents))
191
for _, a := range r.agents {
192
agents = append(agents, a)
193
}
194
data, err := json.MarshalIndent(agents, "", " ")
195
if err != nil {
196
return
197
}
198
_ = os.WriteFile(r.dataPath, data, 0600)
199
}
200
201
// Register creates a new agent, provisions its Ergo account, and returns
202
// credentials and a signed rules-of-engagement payload.
203
// cfg is validated before any provisioning occurs.
204
func (r *Registry) Register(nick string, agentType AgentType, cfg EngagementConfig) (*Credentials, *SignedPayload, error) {
205
if err := cfg.Validate(); err != nil {
206
return nil, nil, fmt.Errorf("registry: invalid engagement config: %w", err)
207
}
208
209
r.mu.Lock()
210
defer r.mu.Unlock()
211
212
if existing, ok := r.agents[nick]; ok && !existing.Revoked {
213
return nil, nil, fmt.Errorf("registry: agent %q already registered", nick)
214
}
215
216
passphrase, err := generatePassphrase()
217
if err != nil {
218
return nil, nil, fmt.Errorf("registry: generate passphrase: %w", err)
219
}
220
221
if err := r.provisioner.RegisterAccount(nick, passphrase); err != nil {
222
// Account exists in NickServ from a previous run — sync the password.
223
if strings.Contains(err.Error(), "ACCOUNT_EXISTS") {
224
if err2 := r.provisioner.ChangePassword(nick, passphrase); err2 != nil {
225
return nil, nil, fmt.Errorf("registry: provision account: %w", err2)
226
}
227
} else {
228
return nil, nil, fmt.Errorf("registry: provision account: %w", err)
229
}
230
}
231
232
agent := &Agent{
233
Nick: nick,
234
Type: agentType,
235
Channels: cfg.Channels,
236
Permissions: cfg.Permissions,
237
Config: cfg,
238
CreatedAt: time.Now(),
239
}
240
r.agents[nick] = agent
241
r.saveOne(agent)
242
243
payload, err := r.signPayload(agent)
244
if err != nil {
245
return nil, nil, fmt.Errorf("registry: sign payload: %w", err)
246
}
247
248
return &Credentials{Nick: nick, Passphrase: passphrase}, payload, nil
249
}
250
251
// Adopt adds a pre-existing NickServ account to the registry without touching
252
// its password. The caller is responsible for knowing their own passphrase.
253
// Returns a signed payload; no Credentials are returned since the password
254
// is not changed.
255
func (r *Registry) Adopt(nick string, agentType AgentType, cfg EngagementConfig) (*SignedPayload, error) {
256
if err := cfg.Validate(); err != nil {
257
return nil, fmt.Errorf("registry: invalid engagement config: %w", err)
258
}
259
260
r.mu.Lock()
261
defer r.mu.Unlock()
262
263
if existing, ok := r.agents[nick]; ok && !existing.Revoked {
264
return nil, fmt.Errorf("registry: agent %q already registered", nick)
265
}
266
267
agent := &Agent{
268
Nick: nick,
269
Type: agentType,
270
Channels: cfg.Channels,
271
Permissions: cfg.Permissions,
272
Config: cfg,
273
CreatedAt: time.Now(),
274
}
275
r.agents[nick] = agent
276
r.saveOne(agent)
277
278
return r.signPayload(agent)
279
}
280
281
// Rotate generates a new passphrase for an agent and updates Ergo.
282
func (r *Registry) Rotate(nick string) (*Credentials, error) {
283
r.mu.Lock()
284
defer r.mu.Unlock()
285
286
if _, err := r.get(nick); err != nil {
287
return nil, err
288
}
289
290
passphrase, err := generatePassphrase()
291
if err != nil {
292
return nil, fmt.Errorf("registry: generate passphrase: %w", err)
293
}
294
295
if err := r.provisioner.ChangePassword(nick, passphrase); err != nil {
296
return nil, fmt.Errorf("registry: rotate credentials: %w", err)
297
}
298
299
// Rotation doesn't change stored agent data, but bump a file save for
300
// consistency; DB backends are unaffected since nothing persisted changed.
301
r.save()
302
return &Credentials{Nick: nick, Passphrase: passphrase}, nil
303
}
304
305
// Revoke locks an agent out by rotating to an unguessable passphrase and
306
// marking it revoked in the registry.
307
func (r *Registry) Revoke(nick string) error {
308
r.mu.Lock()
309
defer r.mu.Unlock()
310
311
agent, err := r.get(nick)
312
if err != nil {
313
return err
314
}
315
316
lockout, err := generatePassphrase()
317
if err != nil {
318
return fmt.Errorf("registry: generate lockout passphrase: %w", err)
319
}
320
321
if err := r.provisioner.ChangePassword(nick, lockout); err != nil {
322
return fmt.Errorf("registry: revoke credentials: %w", err)
323
}
324
325
agent.Revoked = true
326
r.saveOne(agent)
327
return nil
328
}
329
330
// Delete fully removes an agent from the registry. The Ergo NickServ account
331
// is locked out first (password rotated to an unguessable value) so the agent
332
// can no longer connect, then the entry is removed from the registry. If the
333
// agent is already revoked the lockout step is skipped.
334
func (r *Registry) Delete(nick string) error {
335
r.mu.Lock()
336
defer r.mu.Unlock()
337
338
agent, ok := r.agents[nick]
339
if !ok {
340
return fmt.Errorf("registry: agent %q not found", nick)
341
}
342
343
if !agent.Revoked {
344
lockout, err := generatePassphrase()
345
if err != nil {
346
return fmt.Errorf("registry: generate lockout passphrase: %w", err)
347
}
348
if err := r.provisioner.ChangePassword(nick, lockout); err != nil {
349
return fmt.Errorf("registry: delete lockout: %w", err)
350
}
351
}
352
353
delete(r.agents, nick)
354
r.deleteOne(nick)
355
return nil
356
}
357
358
// UpdateChannels replaces the channel list for an active agent.
359
// Used by relay brokers to sync runtime /join and /part changes back to the registry.
360
// Update persists changes to an existing agent record.
361
func (r *Registry) Update(agent *Agent) error {
362
r.mu.Lock()
363
defer r.mu.Unlock()
364
if _, ok := r.agents[agent.Nick]; !ok {
365
return fmt.Errorf("registry: agent %q not found", agent.Nick)
366
}
367
r.agents[agent.Nick] = agent
368
r.saveOne(agent)
369
return nil
370
}
371
372
func (r *Registry) UpdateChannels(nick string, channels []string) error {
373
r.mu.Lock()
374
defer r.mu.Unlock()
375
agent, err := r.get(nick)
376
if err != nil {
377
return err
378
}
379
agent.Channels = append([]string(nil), channels...)
380
agent.Config.Channels = append([]string(nil), channels...)
381
r.saveOne(agent)
382
return nil
383
}
384
385
// Get returns the agent with the given nick.
386
func (r *Registry) Get(nick string) (*Agent, error) {
387
r.mu.RLock()
388
defer r.mu.RUnlock()
389
return r.get(nick)
390
}
391
392
// Touch updates the last-seen timestamp for an agent. Persists to disk
393
// at most once per minute to avoid thrashing on frequent heartbeats.
394
func (r *Registry) Touch(nick string) {
395
r.mu.Lock()
396
defer r.mu.Unlock()
397
a, ok := r.agents[nick]
398
if !ok || a.Revoked {
399
return
400
}
401
now := time.Now()
402
shouldPersist := a.LastSeen == nil || now.Sub(*a.LastSeen) >= time.Minute
403
a.LastSeen = &now
404
if shouldPersist {
405
r.saveOne(a)
406
}
407
}
408
409
const defaultOnlineTimeout = 2 * time.Minute
410
411
// SetOnlineTimeout configures how long since last_seen before an agent
412
// is considered offline. Pass 0 to reset to the default (2 minutes).
413
func (r *Registry) SetOnlineTimeout(d time.Duration) {
414
r.mu.Lock()
415
defer r.mu.Unlock()
416
r.onlineTimeout = d
417
}
418
419
func (r *Registry) getOnlineTimeout() time.Duration {
420
if r.onlineTimeout > 0 {
421
return r.onlineTimeout
422
}
423
return defaultOnlineTimeout
424
}
425
426
// Reap removes agents that haven't been seen in maxAge. Revoked agents
427
// are always reaped if older than maxAge. Returns the number of agents removed.
428
func (r *Registry) Reap(maxAge time.Duration) int {
429
if maxAge <= 0 {
430
return 0
431
}
432
r.mu.Lock()
433
defer r.mu.Unlock()
434
cutoff := time.Now().Add(-maxAge)
435
var reaped int
436
for nick, a := range r.agents {
437
if a.Online {
438
continue
439
}
440
// Use last_seen if available, otherwise fall back to created_at.
441
ref := a.CreatedAt
442
if a.LastSeen != nil {
443
ref = *a.LastSeen
444
}
445
if ref.Before(cutoff) {
446
delete(r.agents, nick)
447
if r.db != nil {
448
_ = r.db.AgentDelete(nick)
449
}
450
reaped++
451
}
452
}
453
if reaped > 0 && r.db == nil {
454
r.save()
455
}
456
return reaped
457
}
458
459
// List returns all registered agents with computed online status.
460
func (r *Registry) List() []*Agent {
461
r.mu.RLock()
462
defer r.mu.RUnlock()
463
threshold := r.getOnlineTimeout()
464
now := time.Now()
465
var out []*Agent
466
for _, a := range r.agents {
467
a.Online = a.LastSeen != nil && now.Sub(*a.LastSeen) < threshold
468
out = append(out, a)
469
}
470
return out
471
}
472
473
func (r *Registry) get(nick string) (*Agent, error) {
474
agent, ok := r.agents[nick]
475
if !ok {
476
return nil, fmt.Errorf("registry: agent %q not found", nick)
477
}
478
if agent.Revoked {
479
return nil, fmt.Errorf("registry: agent %q is revoked", nick)
480
}
481
return agent, nil
482
}
483
484
func (r *Registry) signPayload(agent *Agent) (*SignedPayload, error) {
485
payload := EngagementPayload{
486
V: 1,
487
Nick: agent.Nick,
488
Type: agent.Type,
489
Config: agent.Config,
490
IssuedAt: time.Now(),
491
}
492
493
data, err := json.Marshal(payload)
494
if err != nil {
495
return nil, err
496
}
497
498
mac := hmac.New(sha256.New, r.signingKey)
499
mac.Write(data)
500
sig := hex.EncodeToString(mac.Sum(nil))
501
502
return &SignedPayload{Payload: payload, Signature: sig}, nil
503
}
504
505
// VerifyPayload verifies the HMAC signature on a SignedPayload.
506
func VerifyPayload(sp *SignedPayload, signingKey []byte) error {
507
data, err := json.Marshal(sp.Payload)
508
if err != nil {
509
return err
510
}
511
512
mac := hmac.New(sha256.New, signingKey)
513
mac.Write(data)
514
expected := hex.EncodeToString(mac.Sum(nil))
515
516
if !hmac.Equal([]byte(sp.Signature), []byte(expected)) {
517
return fmt.Errorf("registry: invalid payload signature")
518
}
519
return nil
520
}
521
522
func generatePassphrase() (string, error) {
523
b := make([]byte, 32)
524
if _, err := rand.Read(b); err != nil {
525
return "", err
526
}
527
return hex.EncodeToString(b), nil
528
}
529

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button