1package main
2
3import (
4 "bytes"
5 "encoding/base64"
6 "encoding/json"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/http"
11 "net/url"
12
13 "github.com/urfave/cli/v2"
14)
15
16var cmdRelayAdmin = &cli.Command{
17 Name: "admin",
18 Usage: "sub-comands for relay administration",
19 Flags: []cli.Flag{
20 &cli.StringFlag{
21 Name: "admin-password",
22 Usage: "relay admin password (for Basic admin auth)",
23 EnvVars: []string{"RELAY_ADMIN_PASSWORD", "ATP_AUTH_ADMIN_PASSWORD"},
24 },
25 &cli.StringFlag{
26 Name: "admin-bearer-token",
27 Usage: "relay admin auth token (for Bearer auth)",
28 EnvVars: []string{"RELAY_ADMIN_BEARER_TOKEN"},
29 },
30 },
31 Subcommands: []*cli.Command{
32 &cli.Command{
33 Name: "account",
34 Usage: "sub-commands for managing accounts",
35 Subcommands: []*cli.Command{
36 &cli.Command{
37 Name: "takedown",
38 Usage: "takedown a single account on relay",
39 Flags: []cli.Flag{
40 &cli.StringFlag{
41 Name: "collection",
42 Aliases: []string{"c"},
43 Usage: "collection (NSID) to match",
44 },
45 &cli.BoolFlag{
46 Name: "reverse",
47 Usage: "un-takedown",
48 },
49 },
50 Action: runRelayAdminAccountTakedown,
51 },
52 &cli.Command{
53 Name: "list",
54 Aliases: []string{"ls"},
55 Usage: "enumerate accounts (eg, takendown)",
56 Action: runRelayAdminAccountList,
57 },
58 },
59 },
60 &cli.Command{
61 Name: "host",
62 Usage: "sub-commands for upstream hosts (eg, PDS)",
63 Subcommands: []*cli.Command{
64 &cli.Command{
65 Name: "add",
66 Usage: "request crawl of upstream host (eg, PDS)",
67 ArgsUsage: `<hostname>`,
68 Action: runRelayAdminHostAdd,
69 },
70 &cli.Command{
71 Name: "block",
72 Usage: "request crawl of upstream host (eg, PDS)",
73 ArgsUsage: `<hostname>`,
74 Flags: []cli.Flag{
75 &cli.BoolFlag{
76 Name: "reverse",
77 Usage: "un-takedown",
78 },
79 },
80 Action: runRelayAdminHostBlock,
81 },
82 &cli.Command{
83 Name: "list",
84 Aliases: []string{"ls"},
85 Usage: "enumerate hosts crawled by relay",
86 Action: runRelayAdminHostList,
87 },
88 &cli.Command{
89 Name: "config",
90 Usage: "update rate-limits per host",
91 ArgsUsage: `<hostname>`,
92 Flags: []cli.Flag{
93 &cli.IntFlag{
94 Name: "account-limit",
95 },
96 },
97 Action: runRelayAdminHostConfig,
98 },
99 },
100 },
101 &cli.Command{
102 Name: "domain",
103 Usage: "sub-commands for domain-level config",
104 Subcommands: []*cli.Command{
105 &cli.Command{
106 Name: "ban",
107 Usage: "ban an entire domain name from being crawled",
108 ArgsUsage: `<domain>`,
109 Flags: []cli.Flag{
110 &cli.BoolFlag{
111 Name: "reverse",
112 Usage: "un-takedown",
113 },
114 },
115 Action: runRelayAdminDomainBan,
116 },
117 &cli.Command{
118 Name: "list",
119 Aliases: []string{"ls"},
120 Usage: "enumerate domains with configs (eg, bans)",
121 Action: runRelayAdminDomainList,
122 },
123 },
124 },
125 &cli.Command{
126 Name: "consumer",
127 Usage: "sub-commands for consumers",
128 Subcommands: []*cli.Command{
129 &cli.Command{
130 Name: "list",
131 Aliases: []string{"ls"},
132 Usage: "enumerate consumers",
133 Action: runRelayAdminConsumerList,
134 },
135 },
136 },
137 },
138}
139
140type RelayAdminClient struct {
141 Host string
142 Password string
143 BearerToken string
144}
145
146func (c *RelayAdminClient) Do(method, path string, params map[string]string, body map[string]any) ([]byte, error) {
147 u, err := url.Parse(c.Host)
148 if err != nil {
149 return nil, err
150 }
151 u.Path = path
152 q := u.Query()
153 for k, v := range params {
154 q.Add(k, v)
155 }
156 u.RawQuery = q.Encode()
157
158 var buf *bytes.Buffer
159 if body != nil {
160 b, err := json.Marshal(body)
161 if err != nil {
162 return nil, err
163 }
164 buf = bytes.NewBuffer(b)
165 }
166
167 var req *http.Request
168 if buf != nil {
169 req, err = http.NewRequest(method, u.String(), buf)
170 } else {
171 req, err = http.NewRequest(method, u.String(), nil)
172 }
173 if err != nil {
174 return nil, err
175 }
176 if c.Password != "" {
177 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:"+c.Password)))
178 } else if c.BearerToken != "" {
179 req.Header.Set("Authorization", "Bearer "+c.BearerToken)
180 }
181 req.Header.Set("User-Agent", *userAgent())
182 if buf != nil {
183 req.Header.Set("Content-Type", "application/json")
184 }
185
186 resp, err := http.DefaultClient.Do(req)
187 if err != nil {
188 return nil, err
189 }
190
191 defer resp.Body.Close()
192 respBytes, err := io.ReadAll(resp.Body)
193 if err != nil {
194 return nil, err
195 }
196 if resp.StatusCode != http.StatusOK {
197 slog.Warn("relay HTTP error", "statusCode", resp.StatusCode, "body", string(respBytes))
198 return nil, fmt.Errorf("relay HTTP request failed: %d", resp.StatusCode)
199 }
200 return respBytes, nil
201}
202
203func NewRelayAdminClient(cctx *cli.Context) (*RelayAdminClient, error) {
204 client := RelayAdminClient{
205 Host: cctx.String("relay-host"),
206 Password: cctx.String("admin-password"),
207 BearerToken: cctx.String("admin-bearer-token"),
208 }
209 if client.Password == "" && client.BearerToken == "" {
210 return nil, fmt.Errorf("either admin password or admin bearer token must be provided")
211 }
212 return &client, nil
213}
214
215func runRelayAdminAccountTakedown(cctx *cli.Context) error {
216 ctx := cctx.Context
217
218 username := cctx.Args().First()
219 if username == "" {
220 return fmt.Errorf("need to provide username as an argument")
221 }
222 ident, err := resolveIdent(ctx, username)
223 if err != nil {
224 return err
225 }
226
227 client, err := NewRelayAdminClient(cctx)
228 if err != nil {
229 return err
230 }
231
232 path := "/admin/repo/takeDown"
233 if cctx.Bool("reverse") {
234 path = "/admin/repo/reverseTakedown"
235 }
236
237 body := map[string]any{
238 "did": ident.DID.String(),
239 }
240 _, err = client.Do("POST", path, nil, body)
241 if err != nil {
242 return err
243 }
244 return nil
245}
246
247func runRelayAdminAccountList(cctx *cli.Context) error {
248 client, err := NewRelayAdminClient(cctx)
249 if err != nil {
250 return err
251 }
252 path := "/admin/repo/takedowns"
253 params := map[string]string{
254 "cursor": "",
255 "size": "500",
256 }
257 for {
258 respBytes, err := client.Do("GET", path, params, nil)
259 if err != nil {
260 return err
261 }
262 var resp map[string]any
263 if err := json.Unmarshal(respBytes, &resp); err != nil {
264 return err
265 }
266 for _, d := range resp["dids"].([]any) {
267 fmt.Println(d)
268 }
269 cursor, ok := resp["cursor"]
270 if !ok || cursor == "" {
271 break
272 }
273 params["cursor"] = cursor.(string)
274 }
275 return nil
276}
277
278func runRelayAdminHostAdd(cctx *cli.Context) error {
279
280 hostname := cctx.Args().First()
281 if hostname == "" {
282 return fmt.Errorf("need to provide hostname as an argument")
283 }
284
285 client, err := NewRelayAdminClient(cctx)
286 if err != nil {
287 return err
288 }
289 path := "/admin/pds/requestCrawl"
290 body := map[string]any{
291 "hostname": hostname,
292 }
293 _, err = client.Do("POST", path, nil, body)
294 if err != nil {
295 return err
296 }
297 return nil
298}
299
300func runRelayAdminHostBlock(cctx *cli.Context) error {
301
302 hostname := cctx.Args().First()
303 if hostname == "" {
304 return fmt.Errorf("need to provide hostname as an argument")
305 }
306
307 client, err := NewRelayAdminClient(cctx)
308 if err != nil {
309 return err
310 }
311
312 path := "/admin/pds/block"
313 if cctx.Bool("reverse") {
314 path = "/admin/pds/unblock"
315 }
316
317 params := map[string]string{
318 "host": hostname,
319 }
320 _, err = client.Do("POST", path, params, nil)
321 if err != nil {
322 return err
323 }
324 return nil
325}
326
327func runRelayAdminHostList(cctx *cli.Context) error {
328 client, err := NewRelayAdminClient(cctx)
329 if err != nil {
330 return err
331 }
332 path := "/admin/pds/list"
333
334 respBytes, err := client.Do("GET", path, nil, nil)
335 if err != nil {
336 return err
337 }
338 var rows []map[string]any
339 if err := json.Unmarshal(respBytes, &rows); err != nil {
340 return err
341 }
342 for _, r := range rows {
343 b, err := json.Marshal(r)
344 if err != nil {
345 return nil
346 }
347 fmt.Println(string(b))
348 }
349 return nil
350}
351
352func runRelayAdminHostConfig(cctx *cli.Context) error {
353
354 hostname := cctx.Args().First()
355 if hostname == "" {
356 return fmt.Errorf("need to provide hostname as an argument")
357 }
358
359 client, err := NewRelayAdminClient(cctx)
360 if err != nil {
361 return err
362 }
363
364 path := "/admin/pds/changeLimits"
365
366 body := map[string]any{
367 "host": hostname,
368 }
369 if cctx.IsSet("account-limit") {
370 body["repo_limit"] = cctx.Int("account-limit")
371 }
372
373 _, err = client.Do("POST", path, nil, body)
374 if err != nil {
375 return err
376 }
377 return nil
378}
379
380func runRelayAdminDomainBan(cctx *cli.Context) error {
381
382 domain := cctx.Args().First()
383 if domain == "" {
384 return fmt.Errorf("need to provide domain as an argument")
385 }
386
387 client, err := NewRelayAdminClient(cctx)
388 if err != nil {
389 return err
390 }
391
392 path := "/admin/subs/banDomain"
393 if cctx.Bool("reverse") {
394 path = "/admin/subs/unbanDomain"
395 }
396
397 body := map[string]any{
398 "domain": domain,
399 }
400 _, err = client.Do("POST", path, nil, body)
401 if err != nil {
402 return err
403 }
404 return nil
405}
406
407func runRelayAdminDomainList(cctx *cli.Context) error {
408 client, err := NewRelayAdminClient(cctx)
409 if err != nil {
410 return err
411 }
412 path := "/admin/subs/listDomainBans"
413
414 respBytes, err := client.Do("GET", path, nil, nil)
415 if err != nil {
416 return err
417 }
418 var resp map[string]any
419 if err := json.Unmarshal(respBytes, &resp); err != nil {
420 return err
421 }
422 for _, d := range resp["banned_domains"].([]any) {
423 fmt.Println(d)
424 }
425 return nil
426}
427
428func runRelayAdminConsumerList(cctx *cli.Context) error {
429 client, err := NewRelayAdminClient(cctx)
430 if err != nil {
431 return err
432 }
433 path := "/admin/consumers/list"
434
435 respBytes, err := client.Do("GET", path, nil, nil)
436 if err != nil {
437 return err
438 }
439 var rows []map[string]any
440 if err := json.Unmarshal(respBytes, &rows); err != nil {
441 return err
442 }
443 for _, r := range rows {
444 b, err := json.Marshal(r)
445 if err != nil {
446 return nil
447 }
448 fmt.Println(string(b))
449 }
450 return nil
451}