ScuttleBot

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

Keyboard Shortcuts

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