ScuttleBot

scuttlebot / cmd / scuttlebot / main.go
Blame History Raw 568 lines
1
package main
2
3
import (
4
"context"
5
"crypto/rand"
6
"crypto/tls"
7
"encoding/hex"
8
"flag"
9
"fmt"
10
"log/slog"
11
"net/http"
12
"os"
13
"os/signal"
14
"path/filepath"
15
"strings"
16
"syscall"
17
"time"
18
19
"golang.org/x/crypto/acme/autocert"
20
21
"github.com/conflicthq/scuttlebot/internal/api"
22
"github.com/conflicthq/scuttlebot/internal/auth"
23
"github.com/conflicthq/scuttlebot/internal/bots/bridge"
24
botmanager "github.com/conflicthq/scuttlebot/internal/bots/manager"
25
"github.com/conflicthq/scuttlebot/internal/config"
26
"github.com/conflicthq/scuttlebot/internal/ergo"
27
"github.com/conflicthq/scuttlebot/internal/mcp"
28
"github.com/conflicthq/scuttlebot/internal/registry"
29
"github.com/conflicthq/scuttlebot/internal/store"
30
"github.com/conflicthq/scuttlebot/internal/topology"
31
)
32
33
var version = "dev"
34
35
func main() {
36
configPath := flag.String("config", "scuttlebot.yaml", "path to config file (YAML)")
37
flag.Parse()
38
39
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
40
41
cfg := &config.Config{}
42
cfg.Defaults()
43
if err := cfg.LoadFile(*configPath); err != nil {
44
log.Error("load config", "path", *configPath, "err", err)
45
os.Exit(1)
46
}
47
cfg.ApplyEnv()
48
49
// In managed mode, auto-fetch the ergo binary if not found.
50
if !cfg.Ergo.External {
51
binary, err := ergo.EnsureBinary(cfg.Ergo.BinaryPath, cfg.Ergo.DataDir)
52
if err != nil {
53
log.Error("ergo binary unavailable", "err", err)
54
os.Exit(1)
55
}
56
abs, err := filepath.Abs(binary)
57
if err != nil {
58
log.Error("resolve ergo binary path", "err", err)
59
os.Exit(1)
60
}
61
cfg.Ergo.BinaryPath = abs
62
}
63
64
// Load or generate a stable Ergo management API token.
65
// We persist it to data/ergo-api-token so it survives restarts — without
66
// this the token changes every launch and the NickServ password-rotation
67
// API call fails with 401 because ergo already loaded the old token.
68
ergoTokenPath := filepath.Join(cfg.Ergo.DataDir, "ergo-api-token")
69
if cfg.Ergo.APIToken == "" {
70
if raw, err := os.ReadFile(ergoTokenPath); err == nil && len(raw) > 0 {
71
cfg.Ergo.APIToken = strings.TrimSpace(string(raw))
72
} else {
73
cfg.Ergo.APIToken = mustGenToken()
74
_ = os.WriteFile(ergoTokenPath, []byte(cfg.Ergo.APIToken), 0600)
75
}
76
}
77
78
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
79
defer cancel()
80
81
log.Info("scuttlebot starting", "version", version)
82
83
// Start Ergo.
84
ergoMgr := ergo.NewManager(cfg.Ergo, log)
85
ergoErr := make(chan error, 1)
86
go func() {
87
if err := ergoMgr.Start(ctx); err != nil {
88
ergoErr <- err
89
}
90
}()
91
92
// Wait for Ergo to become healthy before starting the rest.
93
healthCtx, healthCancel := context.WithTimeout(ctx, 30*time.Second)
94
defer healthCancel()
95
for {
96
if _, err := ergoMgr.API().Status(); err == nil {
97
break
98
}
99
select {
100
case <-healthCtx.Done():
101
log.Error("ergo did not become healthy in time")
102
os.Exit(1)
103
case err := <-ergoErr:
104
log.Error("ergo failed to start", "err", err)
105
os.Exit(1)
106
case <-time.After(500 * time.Millisecond):
107
}
108
}
109
log.Info("ergo healthy")
110
111
// Open datastore if configured (SQLite or PostgreSQL).
112
// When not configured, all stores fall back to JSON files in data/.
113
var dataStore *store.Store
114
if cfg.Datastore.Driver != "" && cfg.Datastore.DSN != "" {
115
ds, err := store.Open(cfg.Datastore.Driver, cfg.Datastore.DSN)
116
if err != nil {
117
log.Error("datastore open", "driver", cfg.Datastore.Driver, "err", err)
118
os.Exit(1)
119
}
120
defer ds.Close()
121
dataStore = ds
122
log.Info("datastore open", "driver", cfg.Datastore.Driver)
123
}
124
125
// Build registry backed by Ergo's NickServ API.
126
// Signing key persists so issued payloads stay valid across restarts.
127
signingKeyHex, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "signing_key"))
128
if err != nil {
129
log.Error("signing key", "err", err)
130
os.Exit(1)
131
}
132
reg := registry.New(ergoMgr.API(), []byte(signingKeyHex))
133
if dataStore != nil {
134
if err := reg.SetStore(dataStore); err != nil {
135
log.Error("registry load from store", "err", err)
136
os.Exit(1)
137
}
138
} else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil {
139
log.Error("registry load", "err", err)
140
os.Exit(1)
141
}
142
143
// API key store — per-consumer tokens with scoped permissions.
144
apiKeyStore, err := auth.NewAPIKeyStore(filepath.Join(cfg.Ergo.DataDir, "api_keys.json"))
145
if err != nil {
146
log.Error("api key store", "err", err)
147
os.Exit(1)
148
}
149
// Migrate legacy api_token into key store on first run.
150
if apiKeyStore.IsEmpty() {
151
apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
152
if err != nil {
153
log.Error("api token", "err", err)
154
os.Exit(1)
155
}
156
if _, err := apiKeyStore.Insert("server", apiToken, []auth.Scope{auth.ScopeAdmin}); err != nil {
157
log.Error("migrate api token to key store", "err", err)
158
os.Exit(1)
159
}
160
log.Info("migrated api_token to api_keys.json", "token", apiToken)
161
} else {
162
log.Info("api key store loaded", "keys", len(apiKeyStore.List()))
163
}
164
165
// Start bridge bot (powers the web chat UI).
166
var bridgeBot *bridge.Bot
167
if cfg.Bridge.Enabled {
168
if cfg.Bridge.Password == "" {
169
cfg.Bridge.Password = mustGenToken()
170
}
171
// Ensure the bridge's NickServ account exists with the current password.
172
if err := ergoMgr.API().RegisterAccount(cfg.Bridge.Nick, cfg.Bridge.Password); err != nil {
173
// Account exists from a previous run — update the password so it matches.
174
if err2 := ergoMgr.API().ChangePassword(cfg.Bridge.Nick, cfg.Bridge.Password); err2 != nil {
175
log.Error("bridge account setup failed", "err", err2)
176
os.Exit(1)
177
}
178
}
179
bridgeBot = bridge.New(
180
cfg.Ergo.IRCAddr,
181
cfg.Bridge.Nick,
182
cfg.Bridge.Password,
183
cfg.Bridge.Channels,
184
cfg.Bridge.BufferSize,
185
time.Duration(cfg.Bridge.WebUserTTLMinutes)*time.Minute,
186
log,
187
)
188
go func() {
189
if err := bridgeBot.Start(ctx); err != nil {
190
log.Error("bridge bot error", "err", err)
191
}
192
}()
193
}
194
195
// Topology manager — provisions static channels and enforces autojoin policy.
196
var topoMgr *topology.Manager
197
if len(cfg.Topology.Channels) > 0 || len(cfg.Topology.Types) > 0 {
198
topoPolicy := topology.NewPolicy(cfg.Topology)
199
topoPass := mustGenToken()
200
if err := ergoMgr.API().RegisterAccount(cfg.Topology.Nick, topoPass); err != nil {
201
if err2 := ergoMgr.API().ChangePassword(cfg.Topology.Nick, topoPass); err2 != nil {
202
log.Error("topology account setup failed", "err", err2)
203
os.Exit(1)
204
}
205
}
206
topoMgr = topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, cfg.Ergo.APIToken, topoPolicy, log)
207
topoCtx, topoCancel := context.WithTimeout(ctx, 30*time.Second)
208
if err := topoMgr.Connect(topoCtx); err != nil {
209
topoCancel()
210
log.Error("topology manager connect failed", "err", err)
211
os.Exit(1)
212
}
213
topoCancel()
214
staticChannels := make([]topology.ChannelConfig, 0, len(cfg.Topology.Channels))
215
for _, sc := range cfg.Topology.Channels {
216
staticChannels = append(staticChannels, topology.ChannelConfig{
217
Name: sc.Name,
218
Topic: sc.Topic,
219
Ops: sc.Ops,
220
Voice: sc.Voice,
221
Autojoin: sc.Autojoin,
222
Modes: sc.Modes,
223
})
224
}
225
go func() {
226
if err := topoMgr.Provision(staticChannels); err != nil {
227
log.Error("topology provision failed", "err", err)
228
}
229
}()
230
topoMgr.StartReaper(ctx)
231
go func() {
232
<-ctx.Done()
233
topoMgr.Close()
234
}()
235
}
236
237
// Policy store — persists behavior/agent/logging settings.
238
policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes)
239
if err != nil {
240
log.Error("policy store", "err", err)
241
os.Exit(1)
242
}
243
if dataStore != nil {
244
if err := policyStore.SetStore(dataStore); err != nil {
245
log.Error("policy store load from db", "err", err)
246
os.Exit(1)
247
}
248
}
249
if bridgeBot != nil {
250
bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute)
251
// Deliver on-join instructions when agents join channels.
252
// Skip system bots to avoid flooding the bridge IRC connection on startup.
253
bridgeBot.SetOnUserJoin(func(channel, nick string) {
254
// Don't send on-join to system bots — they already know what to do.
255
systemBots := map[string]bool{
256
"bridge": true, "auditbot": true, "scribe": true, "herald": true,
257
"oracle": true, "warden": true, "scroll": true, "systembot": true,
258
"snitch": true, "sentinel": true, "steward": true, "shepherd": true,
259
"topology": true,
260
}
261
if systemBots[nick] {
262
return
263
}
264
p := policyStore.Get()
265
msg, ok := p.OnJoinMessages[channel]
266
if !ok || msg == "" {
267
return
268
}
269
msg = strings.ReplaceAll(msg, "{nick}", nick)
270
msg = strings.ReplaceAll(msg, "{channel}", channel)
271
for _, line := range strings.Split(msg, "\n") {
272
if line != "" {
273
bridgeBot.Notice(nick, line)
274
}
275
}
276
})
277
}
278
279
// Admin store — bcrypt-hashed admin accounts.
280
adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json"))
281
if err != nil {
282
log.Error("admin store", "err", err)
283
os.Exit(1)
284
}
285
if dataStore != nil {
286
if err := adminStore.SetStore(dataStore); err != nil {
287
log.Error("admin store load from db", "err", err)
288
os.Exit(1)
289
}
290
}
291
if adminStore.IsEmpty() {
292
password := mustGenToken()[:16]
293
if err := adminStore.Add("admin", password); err != nil {
294
log.Error("create default admin", "err", err)
295
os.Exit(1)
296
}
297
log.Info("first run — default admin created", "username", "admin", "password", password, "action", "change this password immediately")
298
}
299
300
// Bot manager — starts/stops system bots based on policy.
301
botMgr := botmanager.New(cfg.Ergo.IRCAddr, cfg.Ergo.DataDir, ergoMgr.API(), &ergoChannelListAdapter{ergoMgr.API()}, log)
302
303
// Wire policy onChange to re-sync bots on every policy update.
304
policyStore.OnChange(func(p api.Policies) {
305
specs := make([]botmanager.BotSpec, len(p.Behaviors))
306
for i, b := range p.Behaviors {
307
specs[i] = botmanager.BotSpec{
308
ID: b.ID,
309
Nick: b.Nick,
310
Enabled: b.Enabled,
311
JoinAllChannels: b.JoinAllChannels,
312
RequiredChannels: b.RequiredChannels,
313
Config: b.Config,
314
}
315
}
316
if bridgeBot != nil {
317
bridgeBot.SetWebUserTTL(time.Duration(p.Bridge.WebUserTTLMinutes) * time.Minute)
318
}
319
if p.AgentPolicy.OnlineTimeoutSecs > 0 {
320
reg.SetOnlineTimeout(time.Duration(p.AgentPolicy.OnlineTimeoutSecs) * time.Second)
321
}
322
botMgr.Sync(ctx, specs)
323
})
324
325
// Initial bot sync — delayed to let the bridge create channels first.
326
// Bots with join_all_channels need channels to exist before they query
327
// the channel list. The bridge takes ~2s to connect and join.
328
go func() {
329
time.Sleep(5 * time.Second)
330
p := policyStore.Get()
331
specs := make([]botmanager.BotSpec, len(p.Behaviors))
332
for i, b := range p.Behaviors {
333
specs[i] = botmanager.BotSpec{
334
ID: b.ID,
335
Nick: b.Nick,
336
Enabled: b.Enabled,
337
JoinAllChannels: b.JoinAllChannels,
338
RequiredChannels: b.RequiredChannels,
339
Config: b.Config,
340
}
341
}
342
botMgr.Sync(ctx, specs)
343
}()
344
345
// Agent reaper — periodically removes stale agents based on policy.
346
go func() {
347
ticker := time.NewTicker(1 * time.Hour)
348
defer ticker.Stop()
349
for {
350
select {
351
case <-ctx.Done():
352
return
353
case <-ticker.C:
354
p := policyStore.Get()
355
if p.AgentPolicy.ReapAfterDays > 0 {
356
maxAge := time.Duration(p.AgentPolicy.ReapAfterDays) * 24 * time.Hour
357
if n := reg.Reap(maxAge); n > 0 {
358
log.Info("reaped stale agents", "count", n, "max_age_days", p.AgentPolicy.ReapAfterDays)
359
}
360
}
361
}
362
}
363
}()
364
365
// Config store — owns write-back to scuttlebot.yaml with history snapshots.
366
cfgStore := api.NewConfigStore(*configPath, *cfg)
367
cfgStore.OnChange(func(updated config.Config) {
368
// Hot-reload topology on config change.
369
if topoMgr != nil {
370
staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels))
371
for _, sc := range updated.Topology.Channels {
372
staticChannels = append(staticChannels, topology.ChannelConfig{
373
Name: sc.Name, Topic: sc.Topic,
374
Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin,
375
Modes: sc.Modes,
376
})
377
}
378
if err := topoMgr.Provision(staticChannels); err != nil {
379
log.Error("topology hot-reload failed", "err", err)
380
}
381
}
382
// Hot-reload bridge web TTL.
383
if bridgeBot != nil {
384
bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute)
385
}
386
// Regenerate ircd.yaml and rehash Ergo on config changes.
387
if ergoMgr != nil {
388
if err := ergoMgr.UpdateConfig(updated.Ergo); err != nil {
389
log.Error("ergo config hot-reload failed", "err", err)
390
} else {
391
log.Info("ergo config reloaded")
392
}
393
}
394
})
395
396
// Start HTTP REST API server.
397
var llmCfg *config.LLMConfig
398
if len(cfg.LLM.Backends) > 0 {
399
llmCfg = &cfg.LLM
400
}
401
// Pass an explicit nil interface when topology is not configured.
402
// A nil *topology.Manager passed as a topologyManager interface is
403
// non-nil (Go nil interface trap) and causes panics in setAgentModes.
404
var topoIface api.TopologyManager
405
if topoMgr != nil {
406
topoIface = topoMgr
407
}
408
apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
409
handler := apiSrv.Handler()
410
411
var httpServer, tlsServer *http.Server
412
413
if cfg.TLS.Domain != "" {
414
certDir := cfg.TLS.CertDir
415
if certDir == "" {
416
certDir = filepath.Join(cfg.Ergo.DataDir, "certs")
417
}
418
if err := os.MkdirAll(certDir, 0700); err != nil {
419
log.Error("create cert dir", "err", err)
420
os.Exit(1)
421
}
422
423
m := &autocert.Manager{
424
Cache: autocert.DirCache(certDir),
425
Prompt: autocert.AcceptTOS,
426
Email: cfg.TLS.Email,
427
HostPolicy: autocert.HostWhitelist(cfg.TLS.Domain),
428
}
429
430
// HTTPS on :443
431
tlsServer = &http.Server{
432
Addr: ":443",
433
Handler: handler,
434
TLSConfig: &tls.Config{GetCertificate: m.GetCertificate},
435
}
436
go func() {
437
log.Info("api server listening (TLS)", "addr", ":443", "domain", cfg.TLS.Domain)
438
if err := tlsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
439
log.Error("tls server error", "err", err)
440
}
441
}()
442
443
// HTTP on :80 — ACME challenge always enabled; also serves API when AllowInsecure.
444
var httpHandler http.Handler
445
if cfg.TLS.AllowInsecure {
446
httpHandler = m.HTTPHandler(handler)
447
} else {
448
httpHandler = m.HTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
449
http.Redirect(w, r, "https://"+cfg.TLS.Domain+r.RequestURI, http.StatusMovedPermanently)
450
}))
451
}
452
httpServer = &http.Server{Addr: ":80", Handler: httpHandler}
453
go func() {
454
log.Info("http server listening", "addr", ":80", "insecure", cfg.TLS.AllowInsecure)
455
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
456
log.Error("http server error", "err", err)
457
}
458
}()
459
} else {
460
// No TLS — plain HTTP on configured addr.
461
httpServer = &http.Server{
462
Addr: cfg.APIAddr,
463
Handler: handler,
464
}
465
go func() {
466
log.Info("api server listening", "addr", httpServer.Addr)
467
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
468
log.Error("api server error", "err", err)
469
}
470
}()
471
}
472
473
// Start MCP server.
474
mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log)
475
mcpServer := &http.Server{
476
Addr: cfg.MCPAddr,
477
Handler: mcpSrv.Handler(),
478
}
479
go func() {
480
log.Info("mcp server listening", "addr", mcpServer.Addr)
481
if err := mcpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
482
log.Error("mcp server error", "err", err)
483
}
484
}()
485
486
<-ctx.Done()
487
log.Info("shutting down")
488
489
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
490
defer shutdownCancel()
491
if httpServer != nil {
492
_ = httpServer.Shutdown(shutdownCtx)
493
}
494
if tlsServer != nil {
495
_ = tlsServer.Shutdown(shutdownCtx)
496
}
497
_ = mcpServer.Shutdown(shutdownCtx)
498
499
log.Info("goodbye")
500
}
501
502
// ergoChannelListAdapter adapts ergo.APIClient to botmanager.ChannelLister.
503
type ergoChannelListAdapter struct {
504
api *ergo.APIClient
505
}
506
507
func (e *ergoChannelListAdapter) ListChannels() ([]string, error) {
508
resp, err := e.api.ListChannels()
509
if err != nil {
510
return nil, err
511
}
512
out := make([]string, len(resp.Channels))
513
for i, ch := range resp.Channels {
514
out[i] = ch.Name
515
}
516
return out, nil
517
}
518
519
// ergoChannelLister adapts ergo.APIClient to mcp.ChannelLister.
520
type ergoChannelLister struct {
521
api *ergo.APIClient
522
}
523
524
func (e *ergoChannelLister) ListChannels() ([]mcp.ChannelInfo, error) {
525
resp, err := e.api.ListChannels()
526
if err != nil {
527
return nil, err
528
}
529
out := make([]mcp.ChannelInfo, len(resp.Channels))
530
for i, ch := range resp.Channels {
531
out[i] = mcp.ChannelInfo{
532
Name: ch.Name,
533
Topic: ch.Topic,
534
Count: ch.UserCount,
535
}
536
}
537
return out, nil
538
}
539
540
func mustGenToken() string {
541
b := make([]byte, 24)
542
if _, err := rand.Read(b); err != nil {
543
fmt.Fprintf(os.Stderr, "failed to generate token: %v\n", err)
544
os.Exit(1)
545
}
546
return hex.EncodeToString(b)
547
}
548
549
// loadOrCreateToken reads a token from path. If the file doesn't exist it
550
// generates a new token, writes it, and returns it.
551
func loadOrCreateToken(path string) (string, error) {
552
data, err := os.ReadFile(path)
553
if err == nil {
554
t := strings.TrimSpace(string(data))
555
if t != "" {
556
return t, nil
557
}
558
}
559
if !os.IsNotExist(err) && err != nil {
560
return "", fmt.Errorf("read token %s: %w", path, err)
561
}
562
token := mustGenToken()
563
if err := os.WriteFile(path, []byte(token+"\n"), 0600); err != nil {
564
return "", fmt.Errorf("write token %s: %w", path, err)
565
}
566
return token, nil
567
}
568

Keyboard Shortcuts

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