|
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
|
|