ScuttleBot

scuttlebot / internal / bots / snitch / snitch.go
Blame History Raw 350 lines
1
// Package snitch implements a surveillance bot that watches for erratic
2
// behaviour across IRC channels and alerts operators via DM or a
3
// dedicated alert channel.
4
//
5
// Detected conditions:
6
// - Message flooding (burst above threshold in a rolling window)
7
// - Rapid join/part cycling
8
// - Repeated malformed / non-JSON messages from registered agents
9
package snitch
10
11
import (
12
"context"
13
"fmt"
14
"log/slog"
15
"net"
16
"strconv"
17
"strings"
18
"sync"
19
"time"
20
21
"github.com/lrstanley/girc"
22
23
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
24
)
25
26
const defaultNick = "snitch"
27
28
// Config controls snitch's thresholds and alert destination.
29
type Config struct {
30
// IRCAddr is host:port of the Ergo IRC server.
31
IRCAddr string
32
// Nick is the IRC nick for the bot. Default: "snitch".
33
Nick string
34
// Password is the SASL PLAIN passphrase for the bot's NickServ account.
35
Password string
36
37
// AlertChannel is the channel to post alerts to (e.g. "#ops").
38
// If empty, alerts are sent only as DMs to AlertNicks.
39
AlertChannel string
40
// AlertNicks is the list of operator nicks to DM on an alert.
41
AlertNicks []string
42
43
// FloodMessages is the number of messages in FloodWindow that triggers
44
// a flood alert. Default: 10.
45
FloodMessages int
46
// FloodWindow is the rolling window for flood detection. Default: 5s.
47
FloodWindow time.Duration
48
// JoinPartThreshold is join+part events in JoinPartWindow to trigger alert. Default: 5.
49
JoinPartThreshold int
50
// JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
51
JoinPartWindow time.Duration
52
53
// Channels is the list of channels to join on connect.
54
Channels []string
55
56
// MonitorNicks is the list of nicks to track via IRC MONITOR.
57
// Snitch will alert when a monitored nick goes offline unexpectedly.
58
MonitorNicks []string
59
}
60
61
func (c *Config) setDefaults() {
62
if c.Nick == "" {
63
c.Nick = defaultNick
64
}
65
if c.FloodMessages == 0 {
66
c.FloodMessages = 10
67
}
68
if c.FloodWindow == 0 {
69
c.FloodWindow = 5 * time.Second
70
}
71
if c.JoinPartThreshold == 0 {
72
c.JoinPartThreshold = 5
73
}
74
if c.JoinPartWindow == 0 {
75
c.JoinPartWindow = 30 * time.Second
76
}
77
}
78
79
// nickWindow tracks event timestamps for a single nick in a single channel.
80
type nickWindow struct {
81
msgs []time.Time
82
joinPart []time.Time
83
}
84
85
func (nw *nickWindow) trim(now time.Time, msgWindow, jpWindow time.Duration) {
86
cutMsg := now.Add(-msgWindow)
87
filtered := nw.msgs[:0]
88
for _, t := range nw.msgs {
89
if t.After(cutMsg) {
90
filtered = append(filtered, t)
91
}
92
}
93
nw.msgs = filtered
94
95
cutJP := now.Add(-jpWindow)
96
filteredJP := nw.joinPart[:0]
97
for _, t := range nw.joinPart {
98
if t.After(cutJP) {
99
filteredJP = append(filteredJP, t)
100
}
101
}
102
nw.joinPart = filteredJP
103
}
104
105
// Bot is the snitch bot.
106
type Bot struct {
107
cfg Config
108
log *slog.Logger
109
client *girc.Client
110
111
mu sync.Mutex
112
windows map[string]map[string]*nickWindow // channel → nick → window
113
alerted map[string]time.Time // key → last alert time (cooldown)
114
}
115
116
// New creates a snitch Bot.
117
func New(cfg Config, log *slog.Logger) *Bot {
118
cfg.setDefaults()
119
return &Bot{
120
cfg: cfg,
121
log: log,
122
windows: make(map[string]map[string]*nickWindow),
123
alerted: make(map[string]time.Time),
124
}
125
}
126
127
// Start connects to IRC and begins surveillance. Blocks until ctx is done.
128
func (b *Bot) Start(ctx context.Context) error {
129
host, port, err := splitHostPort(b.cfg.IRCAddr)
130
if err != nil {
131
return fmt.Errorf("snitch: %w", err)
132
}
133
134
c := girc.New(girc.Config{
135
Server: host,
136
Port: port,
137
Nick: b.cfg.Nick,
138
User: b.cfg.Nick,
139
Name: "scuttlebot snitch",
140
SASL: &girc.SASLPlain{User: b.cfg.Nick, Pass: b.cfg.Password},
141
PingDelay: 30 * time.Second,
142
PingTimeout: 30 * time.Second,
143
})
144
145
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
146
cl.Cmd.Mode(cl.GetNick(), "+B")
147
for _, ch := range b.cfg.Channels {
148
cl.Cmd.Join(ch)
149
}
150
if b.cfg.AlertChannel != "" {
151
cl.Cmd.Join(b.cfg.AlertChannel)
152
}
153
if len(b.cfg.MonitorNicks) > 0 {
154
cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ","))
155
}
156
if b.log != nil {
157
b.log.Info("snitch connected", "channels", b.cfg.Channels, "monitor", b.cfg.MonitorNicks)
158
}
159
})
160
161
// away-notify: track agents going idle or returning.
162
c.Handlers.AddBg(girc.AWAY, func(_ *girc.Client, e girc.Event) {
163
if e.Source == nil {
164
return
165
}
166
nick := e.Source.Name
167
reason := e.Last()
168
if reason != "" {
169
b.alert(fmt.Sprintf("agent away: %s (%s)", nick, reason))
170
}
171
})
172
173
c.Handlers.AddBg(girc.RPL_MONOFFLINE, func(_ *girc.Client, e girc.Event) {
174
nicks := e.Last()
175
for _, nick := range strings.Split(nicks, ",") {
176
nick = strings.TrimSpace(nick)
177
if nick == "" {
178
continue
179
}
180
b.alert(fmt.Sprintf("monitored nick offline: %s", nick))
181
}
182
})
183
184
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
185
if ch := e.Last(); strings.HasPrefix(ch, "#") {
186
cl.Cmd.Join(ch)
187
}
188
})
189
190
c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
191
if len(e.Params) < 1 || e.Source == nil || e.Source.Name == b.cfg.Nick {
192
return
193
}
194
b.recordJoinPart(e.Params[0], e.Source.Name)
195
})
196
197
c.Handlers.AddBg(girc.PART, func(_ *girc.Client, e girc.Event) {
198
if len(e.Params) < 1 || e.Source == nil {
199
return
200
}
201
b.recordJoinPart(e.Params[0], e.Source.Name)
202
})
203
204
router := cmdparse.NewRouter(b.cfg.Nick)
205
router.Register(cmdparse.Command{
206
Name: "status",
207
Usage: "STATUS",
208
Description: "show current active alerts",
209
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
210
})
211
router.Register(cmdparse.Command{
212
Name: "acknowledge",
213
Usage: "ACKNOWLEDGE <alert-id>",
214
Description: "acknowledge an alert",
215
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
216
})
217
218
c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
219
if len(e.Params) < 1 || e.Source == nil {
220
return
221
}
222
// Dispatch commands (DMs and channel messages).
223
if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
224
c.Cmd.Message(reply.Target, reply.Text)
225
return
226
}
227
channel := e.Params[0]
228
nick := e.Source.Name
229
if nick == b.cfg.Nick {
230
return
231
}
232
b.recordMsg(channel, nick)
233
b.checkFlood(c, channel, nick)
234
})
235
236
b.client = c
237
238
errCh := make(chan error, 1)
239
go func() {
240
if err := c.Connect(); err != nil && ctx.Err() == nil {
241
errCh <- err
242
}
243
}()
244
245
select {
246
case <-ctx.Done():
247
c.Close()
248
return nil
249
case err := <-errCh:
250
return fmt.Errorf("snitch: irc: %w", err)
251
}
252
}
253
254
func (b *Bot) JoinChannel(channel string) {
255
if b.client != nil {
256
b.client.Cmd.Join(channel)
257
}
258
}
259
260
// MonitorAdd adds nicks to the MONITOR list at runtime.
261
func (b *Bot) MonitorAdd(nicks ...string) {
262
if b.client != nil && len(nicks) > 0 {
263
b.client.Cmd.SendRawf("MONITOR + %s", strings.Join(nicks, ","))
264
}
265
}
266
267
// MonitorRemove removes nicks from the MONITOR list at runtime.
268
func (b *Bot) MonitorRemove(nicks ...string) {
269
if b.client != nil && len(nicks) > 0 {
270
b.client.Cmd.SendRawf("MONITOR - %s", strings.Join(nicks, ","))
271
}
272
}
273
274
func (b *Bot) window(channel, nick string) *nickWindow {
275
if b.windows[channel] == nil {
276
b.windows[channel] = make(map[string]*nickWindow)
277
}
278
if b.windows[channel][nick] == nil {
279
b.windows[channel][nick] = &nickWindow{}
280
}
281
return b.windows[channel][nick]
282
}
283
284
func (b *Bot) recordMsg(channel, nick string) {
285
b.mu.Lock()
286
defer b.mu.Unlock()
287
now := time.Now()
288
w := b.window(channel, nick)
289
w.trim(now, b.cfg.FloodWindow, b.cfg.JoinPartWindow)
290
w.msgs = append(w.msgs, now)
291
}
292
293
func (b *Bot) recordJoinPart(channel, nick string) {
294
b.mu.Lock()
295
defer b.mu.Unlock()
296
now := time.Now()
297
w := b.window(channel, nick)
298
w.trim(now, b.cfg.FloodWindow, b.cfg.JoinPartWindow)
299
w.joinPart = append(w.joinPart, now)
300
if len(w.joinPart) >= b.cfg.JoinPartThreshold {
301
go b.alert(fmt.Sprintf("join/part cycling: %s in %s (%d events in %s)",
302
nick, channel, len(w.joinPart), b.cfg.JoinPartWindow))
303
w.joinPart = nil
304
}
305
}
306
307
func (b *Bot) checkFlood(c *girc.Client, channel, nick string) {
308
b.mu.Lock()
309
defer b.mu.Unlock()
310
now := time.Now()
311
w := b.window(channel, nick)
312
w.trim(now, b.cfg.FloodWindow, b.cfg.JoinPartWindow)
313
if len(w.msgs) >= b.cfg.FloodMessages {
314
key := "flood:" + channel + ":" + nick
315
if last, ok := b.alerted[key]; !ok || now.Sub(last) > 60*time.Second {
316
b.alerted[key] = now
317
go b.alert(fmt.Sprintf("flood detected: %s in %s (%d msgs in %s)",
318
nick, channel, len(w.msgs), b.cfg.FloodWindow))
319
}
320
}
321
}
322
323
func (b *Bot) alert(msg string) {
324
if b.client == nil {
325
return
326
}
327
if b.log != nil {
328
b.log.Warn("snitch alert", "msg", msg)
329
}
330
full := "[snitch] " + msg
331
if b.cfg.AlertChannel != "" {
332
b.client.Cmd.Message(b.cfg.AlertChannel, full)
333
}
334
for _, nick := range b.cfg.AlertNicks {
335
b.client.Cmd.Message(nick, full)
336
}
337
}
338
339
func splitHostPort(addr string) (string, int, error) {
340
host, portStr, err := net.SplitHostPort(addr)
341
if err != nil {
342
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
343
}
344
port, err := strconv.Atoi(portStr)
345
if err != nil {
346
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
347
}
348
return host, port, nil
349
}
350

Keyboard Shortcuts

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