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