ScuttleBot

scuttlebot / internal / topology / topology.go
Blame History Raw 384 lines
1
// Package topology manages IRC channel provisioning.
2
//
3
// The Manager connects to Ergo as a privileged oper account and provisions
4
// channels via ChanServ: registration, topics, and access lists (ops/voice).
5
// Users define topology in scuttlebot config; this package creates and
6
// maintains it in Ergo.
7
package topology
8
9
import (
10
"context"
11
"fmt"
12
"log/slog"
13
"strings"
14
"sync"
15
"time"
16
17
"github.com/lrstanley/girc"
18
)
19
20
// ChannelConfig describes a channel to provision.
21
type ChannelConfig struct {
22
// Name is the full channel name including the # prefix.
23
// Convention: #fleet, #project.{name}, #project.{name}.{topic}
24
Name string
25
26
// Topic is the initial channel topic (shared state header).
27
Topic string
28
29
// Ops is a list of nicks to grant +o (channel operator) status via AMODE.
30
Ops []string
31
32
// Voice is a list of nicks to grant +v status via AMODE.
33
Voice []string
34
35
// Autojoin is a list of bot nicks to invite after provisioning.
36
Autojoin []string
37
38
// Modes is a list of channel modes to set (e.g. "+m" for moderated).
39
Modes []string
40
41
// OnJoinMessage is sent to agents when they join this channel.
42
OnJoinMessage string
43
}
44
45
// channelRecord tracks a provisioned channel for TTL-based reaping.
46
type channelRecord struct {
47
name string
48
provisionedAt time.Time
49
}
50
51
// Manager provisions and maintains IRC channel topology.
52
type Manager struct {
53
ircAddr string
54
nick string
55
password string
56
operPass string // oper password for SAMODE access
57
log *slog.Logger
58
policy *Policy
59
client *girc.Client
60
61
mu sync.Mutex
62
channels map[string]channelRecord // channel name → record
63
}
64
65
// NewManager creates a topology Manager. nick and password are the Ergo
66
// credentials of the scuttlebot oper account used to manage channels.
67
// policy may be nil if the caller only uses the manager for ad-hoc provisioning.
68
func NewManager(ircAddr, nick, password, operPass string, policy *Policy, log *slog.Logger) *Manager {
69
return &Manager{
70
ircAddr: ircAddr,
71
nick: nick,
72
password: password,
73
operPass: operPass,
74
policy: policy,
75
log: log,
76
channels: make(map[string]channelRecord),
77
}
78
}
79
80
// Policy returns the policy attached to this manager, or nil.
81
func (m *Manager) Policy() *Policy { return m.policy }
82
83
// Connect establishes the IRC connection used for channel management.
84
// Call before Provision.
85
func (m *Manager) Connect(ctx context.Context) error {
86
host, port, err := splitHostPort(m.ircAddr)
87
if err != nil {
88
return fmt.Errorf("topology: parse irc addr: %w", err)
89
}
90
91
c := girc.New(girc.Config{
92
Server: host,
93
Port: port,
94
Nick: m.nick,
95
User: "scuttlebot",
96
Name: "scuttlebot topology manager",
97
SASL: &girc.SASLPlain{User: m.nick, Pass: m.password},
98
SSL: false,
99
})
100
101
connected := make(chan struct{})
102
c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
103
// OPER up for SAMODE access.
104
if m.operPass != "" {
105
client.Cmd.SendRawf("OPER scuttlebot %s", m.operPass)
106
}
107
close(connected)
108
})
109
110
go func() {
111
if err := c.Connect(); err != nil {
112
m.log.Error("topology irc connection error", "err", err)
113
}
114
}()
115
116
select {
117
case <-connected:
118
m.client = c
119
return nil
120
case <-ctx.Done():
121
c.Close()
122
return ctx.Err()
123
case <-time.After(30 * time.Second):
124
c.Close()
125
return fmt.Errorf("topology: timed out connecting to IRC")
126
}
127
}
128
129
// Close disconnects from IRC.
130
func (m *Manager) Close() {
131
if m.client != nil {
132
m.client.Close()
133
}
134
}
135
136
// Provision creates and configures a set of channels. It is idempotent —
137
// calling it multiple times with the same config is safe.
138
func (m *Manager) Provision(channels []ChannelConfig) error {
139
if m.client == nil {
140
return fmt.Errorf("topology: not connected — call Connect first")
141
}
142
for _, ch := range channels {
143
if err := ValidateName(ch.Name); err != nil {
144
return err
145
}
146
if err := m.provision(ch); err != nil {
147
return err
148
}
149
}
150
return nil
151
}
152
153
// SetTopic updates the topic on an existing channel.
154
func (m *Manager) SetTopic(channel, topic string) error {
155
if m.client == nil {
156
return fmt.Errorf("topology: not connected")
157
}
158
m.chanserv("TOPIC %s %s", channel, topic)
159
return nil
160
}
161
162
// ProvisionEphemeral creates a short-lived task channel.
163
// Convention: #task.{id}
164
func (m *Manager) ProvisionEphemeral(id string) (string, error) {
165
name := "#task." + id
166
if err := ValidateName(name); err != nil {
167
return "", err
168
}
169
if err := m.provision(ChannelConfig{Name: name}); err != nil {
170
return "", err
171
}
172
return name, nil
173
}
174
175
// DestroyEphemeral drops an ephemeral task channel.
176
func (m *Manager) DestroyEphemeral(channel string) {
177
m.chanserv("DROP %s", channel)
178
}
179
180
// ProvisionChannel provisions a single channel and invites its autojoin nicks.
181
// It applies the manager's Policy if set; the caller may override autojoin via
182
// the ChannelConfig directly.
183
func (m *Manager) ProvisionChannel(ch ChannelConfig) error {
184
if err := ValidateName(ch.Name); err != nil {
185
return err
186
}
187
if err := m.provision(ch); err != nil {
188
return err
189
}
190
if len(ch.Autojoin) > 0 {
191
m.Invite(ch.Name, ch.Autojoin)
192
}
193
return nil
194
}
195
196
// Invite sends IRC INVITE to each nick in nicks for the given channel.
197
// Invite is best-effort: nicks that are not connected are silently skipped.
198
func (m *Manager) Invite(channel string, nicks []string) {
199
if m.client == nil {
200
return
201
}
202
for _, nick := range nicks {
203
m.client.Cmd.Invite(nick, channel)
204
}
205
}
206
207
func (m *Manager) provision(ch ChannelConfig) error {
208
// Register with ChanServ (idempotent — fails silently if already registered).
209
m.chanserv("REGISTER %s", ch.Name)
210
time.Sleep(200 * time.Millisecond) // one short wait for ChanServ to process
211
212
if ch.Topic != "" {
213
m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
214
}
215
216
// Apply channel modes (e.g. +m for moderated).
217
for _, mode := range ch.Modes {
218
m.client.Cmd.Mode(ch.Name, mode)
219
}
220
221
// Fire ChanServ AMODE grants asynchronously — persistent, auto-applied on join.
222
if len(ch.Ops) > 0 || len(ch.Voice) > 0 {
223
go func(name string, ops, voice []string) {
224
for _, nick := range ops {
225
m.chanserv("AMODE %s +o %s", name, nick)
226
}
227
for _, nick := range voice {
228
m.chanserv("AMODE %s +v %s", name, nick)
229
}
230
}(ch.Name, ch.Ops, ch.Voice)
231
}
232
233
if len(ch.Autojoin) > 0 {
234
m.Invite(ch.Name, ch.Autojoin)
235
}
236
237
m.mu.Lock()
238
m.channels[ch.Name] = channelRecord{name: ch.Name, provisionedAt: time.Now()}
239
m.mu.Unlock()
240
241
m.log.Info("provisioned channel", "channel", ch.Name)
242
return nil
243
}
244
245
// DropChannel drops an IRC channel via ChanServ DROP and removes it from the
246
// channel registry. Use for ephemeral channels that have expired or been closed.
247
func (m *Manager) DropChannel(channel string) {
248
m.chanserv("DROP %s", channel)
249
m.mu.Lock()
250
delete(m.channels, channel)
251
m.mu.Unlock()
252
m.log.Info("dropped channel", "channel", channel)
253
}
254
255
// StartReaper starts a background goroutine that drops ephemeral channels once
256
// their TTL has elapsed. The reaper runs until ctx is cancelled.
257
// Policy must be set on the Manager for TTL rules to be evaluated.
258
func (m *Manager) StartReaper(ctx context.Context) {
259
if m.policy == nil {
260
return
261
}
262
go func() {
263
ticker := time.NewTicker(5 * time.Minute)
264
defer ticker.Stop()
265
for {
266
select {
267
case <-ctx.Done():
268
return
269
case <-ticker.C:
270
m.reap()
271
}
272
}
273
}()
274
}
275
276
func (m *Manager) reap() {
277
now := time.Now()
278
m.mu.Lock()
279
expired := make([]channelRecord, 0)
280
for _, rec := range m.channels {
281
ttl := m.policy.TTLFor(rec.name)
282
if ttl > 0 && m.policy.IsEphemeral(rec.name) && now.Sub(rec.provisionedAt) > ttl {
283
expired = append(expired, rec)
284
}
285
}
286
m.mu.Unlock()
287
for _, rec := range expired {
288
m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
289
m.DropChannel(rec.name)
290
}
291
}
292
293
// GrantAccess sets a ChanServ AMODE entry for nick on the given channel.
294
// level is "OP" or "VOICE". AMODE persists across reconnects — ChanServ
295
// automatically applies the mode every time the nick joins.
296
func (m *Manager) GrantAccess(nick, channel, level string) {
297
if m.client == nil || level == "" {
298
return
299
}
300
switch strings.ToUpper(level) {
301
case "OP":
302
m.chanserv("AMODE %s +o %s", channel, nick)
303
case "VOICE":
304
m.chanserv("AMODE %s +v %s", channel, nick)
305
default:
306
m.log.Warn("unknown access level", "level", level)
307
return
308
}
309
m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level)
310
}
311
312
// RevokeAccess removes ChanServ AMODE entries for nick on the given channel.
313
func (m *Manager) RevokeAccess(nick, channel string) {
314
if m.client == nil {
315
return
316
}
317
m.chanserv("AMODE %s -o %s", channel, nick)
318
m.chanserv("AMODE %s -v %s", channel, nick)
319
m.log.Info("revoked channel access (AMODE)", "nick", nick, "channel", channel)
320
}
321
322
func (m *Manager) chanserv(format string, args ...any) {
323
msg := fmt.Sprintf(format, args...)
324
m.client.Cmd.Message("ChanServ", msg)
325
}
326
327
// ChannelInfo describes an active provisioned channel.
328
type ChannelInfo struct {
329
Name string `json:"name"`
330
ProvisionedAt time.Time `json:"provisioned_at"`
331
Type string `json:"type,omitempty"`
332
Ephemeral bool `json:"ephemeral,omitempty"`
333
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
334
}
335
336
// ListChannels returns all actively provisioned channels.
337
func (m *Manager) ListChannels() []ChannelInfo {
338
m.mu.Lock()
339
defer m.mu.Unlock()
340
out := make([]ChannelInfo, 0, len(m.channels))
341
for _, rec := range m.channels {
342
ci := ChannelInfo{
343
Name: rec.name,
344
ProvisionedAt: rec.provisionedAt,
345
}
346
if m.policy != nil {
347
ci.Type = m.policy.TypeName(rec.name)
348
ci.Ephemeral = m.policy.IsEphemeral(rec.name)
349
ttl := m.policy.TTLFor(rec.name)
350
if ttl > 0 {
351
ci.TTLSeconds = int64(ttl.Seconds())
352
}
353
}
354
out = append(out, ci)
355
}
356
return out
357
}
358
359
// ValidateName checks that a channel name follows scuttlebot conventions.
360
func ValidateName(name string) error {
361
if !strings.HasPrefix(name, "#") {
362
return fmt.Errorf("topology: channel name must start with #: %q", name)
363
}
364
if len(name) < 2 {
365
return fmt.Errorf("topology: channel name too short: %q", name)
366
}
367
if strings.Contains(name, " ") {
368
return fmt.Errorf("topology: channel name must not contain spaces: %q", name)
369
}
370
return nil
371
}
372
373
func splitHostPort(addr string) (string, int, error) {
374
parts := strings.SplitN(addr, ":", 2)
375
if len(parts) != 2 {
376
return "", 0, fmt.Errorf("invalid address %q (expected host:port)", addr)
377
}
378
var port int
379
if _, err := fmt.Sscan(parts[1], &port); err != nil {
380
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
381
}
382
return parts[0], port, nil
383
}
384

Keyboard Shortcuts

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