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