···16161717.PHONY: build
1818build: ## Build all executables
1919- go build ./cmd/goat
2019 go build ./cmd/gosky
2120 go build ./cmd/bigsky
2221 go build ./cmd/relay
···11`goat`: Go AT protocol CLI tool
22===============================
3344-**NOTE: this project is moving to a dedicated git repo at [bluesky-social/goat](https://github.com/bluesky-social/goat). This copy of the code is deprecated and will eventually be removed, though a notice will remain.**
55-66-77-This is a re-implementation of [adenosine-cli](https://gitlab.com/bnewbold/adenosine/-/tree/main/adenosine-cli?ref_type=heads) in golang.
88-99-1010-## Install
1111-1212-If you have the Go toolchain installed and configured correctly, you can directly build and install the tool for your local account:
1313-1414-```bash
1515-go install github.com/bluesky-social/goat@latest
1616-```
1717-1818-A more manual way to install is:
1919-2020-```bash
2121-git clone https://github.com/bluesky-social/goat
2222-go build .
2323-sudo cp goat /usr/local/bin
2424-```
2525-2626-The intention is to also provide a Homebrew "cask" and Debian/Ubuntu packages.
2727-2828-2929-## Usage
3030-3131-`goat` is relatively self-documenting via help pages:
3232-3333-```bash
3434-goat --help
3535-goat bsky -h
3636-goat help bsky
3737-# etc
3838-```
3939-4040-Most commands use public APIs are don't require authentication. Some commands, like creating records, require an atproto account. You can log in using an "app password" with `goat account login -u <handle> -p <app-password>`.
4141-4242-WARNING: `goat` will store both the app password and authentication tokens in the current users home directory, in cleartext. `goat logout` will wipe the file. Intention is to eventually support configuration via environment variables to keep sensitive state in a password manager or otherwise not-cleartext-on-disk.
4343-4444-Some commands output JSON, and you can use tools like `jq` to process them.
4545-4646-## Examples
4747-4848-Resolve an account's identity in the network:
4949-5050-```bash
5151-$ goat resolve wyden.senate.gov
5252-{
5353- "id": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc",
5454- "alsoKnownAs": [
5555- "at://wyden.senate.gov"
5656- ],
5757- "verificationMethod": [
5858- {
5959- "id": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc#atproto",
6060- "type": "Multikey",
6161- "controller": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc",
6262- "publicKeyMultibase": "zQ3shuMW7q4KBdsFcdvebGi2EVv8KcqS24tF9Pg7Wh5NLB2NM"
6363- }
6464- ],
6565- "service": [
6666- {
6767- "id": "#atproto_pds",
6868- "type": "AtprotoPersonalDataServer",
6969- "serviceEndpoint": "https://shimeji.us-east.host.bsky.network"
7070- }
7171- ]
7272-}
7373-```
7474-7575-List record collection types for an account:
7676-7777-```bash
7878-$ goat ls -c dril.bsky.social
7979-app.bsky.actor.profile
8080-app.bsky.feed.post
8181-app.bsky.feed.repost
8282-app.bsky.graph.follow
8383-chat.bsky.actor.declaration
8484-```
8585-8686-Fetch a record from the network as JSON:
8787-8888-```bash
8989-$ goat get at://dril.bsky.social/app.bsky.feed.post/3kkreaz3amd27
9090-{
9191- "$type": "app.bsky.feed.post",
9292- "createdAt": "2024-02-06T18:15:19.802Z",
9393- "langs": [
9494- "en"
9595- ],
9696- "text": "I do not Fucking recall them asking the blue sky elders permission to open registration to commoners ."
9797-}
9898-```
9999-100100-Make a public snapshot of your account:
101101-102102-```bash
103103-$ goat repo export jay.bsky.team
104104-downloading from https://morel.us-east.host.bsky.network to: jay.bsky.team.20240811183155.car
105105-106106-$ downloading blobs to: jay.bsky.team_blobs
107107-jay.bsky.team_blobs/bafkreia2x4faux5y7v7v54yl5ebkbaek7z7nhmsd4cooubz3yj4zox34cq downloaded
108108-jay.bsky.team_blobs/bafkreia3qgbww7odprmysd6jcyxoh5sczkwoxinnmzpsp73gs623fqfm3a downloaded
109109-jay.bsky.team_blobs/bafkreia3rgnywdrysy65vid42ulyno2cybxhxrn3ragm7cw3smmsxzvbs4 downloaded
110110-[...]
111111-```
112112-113113-Show PLC history for a single account, or make a snapshot of all PLC records (this takes a while), or monitor new ops:
114114-115115-```bash
116116-$ goat plc history atproto.com
117117-[...]
118118-119119-$ goat plc dump | pv -l | gzip > plc_snapshot.json.gz
120120-[...]
121121-122122-$ goat plc dump --cursor now --tail
123123-[...]
124124-```
125125-126126-Verify syntax and generate TIDs:
127127-128128-```bash
129129-$ goat syntax handle check xn--fiqa61au8b7zsevnm8ak20mc4a87e.xn--fiqs8s
130130-valid
131131-132132-$ goat syntax rkey check dHJ1ZQ==
133133-error: recordkey syntax didn't validate via regex
134134-135135-$ goat syntax tid inspect 3kzifvcppte22
136136-Timestamp (UTC): 2024-08-12T02:08:03.29Z
137137-Timestamp (Local): 2024-08-11T19:08:03-07:00
138138-ClockID: 0
139139-uint64: 0x187dcbda2b5ca800
140140-```
141141-142142-The `firehose` commands subscribes to the repo commit stream from a Relay. The default stream outputs event metadata, but doesn't include record blocks (bytes). The `--ops` variant will unpack records and output one line per record operation (instead of one line per commit event), and includes the record values themselves. Some example invocations:
143143-144144-```bash
145145-# possible handle updates
146146-$ goat firehose --account-events | jq .payload.handle
147147-[...]
148148-149149-# text of posts (empty lines for post-deletions)
150150-$ goat firehose - app.bsky.feed.post --ops | jq .record.text
151151-[...]
152152-153153-# sample ratio of languages in current posts
154154-$ goat firehose --ops -c app.bsky.feed.post | head -n100 | jq .record.langs[0] -c | sort | uniq -c | sort -nr
155155- 51 "en"
156156- 33 "ja"
157157- 7 null
158158- 3 "pt"
159159- 2 "ko"
160160- 1 "th"
161161- 1 "id"
162162- 1 "es"
163163- 1 "am"
164164-```
165165-166166-A minimal bsky posting interface, requires account login:
167167-168168-```bash
169169-$ goat bsky post "hello from goat"
170170-```
44+**NOTE: this project has been moved to a dedicated git repo at [bluesky-social/goat](https://github.com/bluesky-social/goat)**
-521
cmd/goat/account.go
···11-package main
22-33-import (
44- "context"
55- "encoding/json"
66- "fmt"
77- "strings"
88- "time"
99-1010- comatproto "github.com/bluesky-social/indigo/api/atproto"
1111- "github.com/bluesky-social/indigo/atproto/auth"
1212- "github.com/bluesky-social/indigo/atproto/crypto"
1313- "github.com/bluesky-social/indigo/atproto/syntax"
1414- "github.com/bluesky-social/indigo/xrpc"
1515-1616- "github.com/urfave/cli/v2"
1717-)
1818-1919-var cmdAccount = &cli.Command{
2020- Name: "account",
2121- Usage: "sub-commands for auth and account management",
2222- Flags: []cli.Flag{},
2323- Subcommands: []*cli.Command{
2424- &cli.Command{
2525- Name: "login",
2626- Usage: "create session with PDS instance",
2727- Flags: []cli.Flag{
2828- &cli.StringFlag{
2929- Name: "username",
3030- Aliases: []string{"u"},
3131- Required: true,
3232- Usage: "account identifier (handle or DID)",
3333- EnvVars: []string{"ATP_AUTH_USERNAME"},
3434- },
3535- &cli.StringFlag{
3636- Name: "app-password",
3737- Aliases: []string{"p"},
3838- Required: true,
3939- Usage: "password (app password recommended)",
4040- EnvVars: []string{"ATP_AUTH_PASSWORD"},
4141- },
4242- &cli.StringFlag{
4343- Name: "auth-factor-token",
4444- Usage: "token required if password is used and 2fa is required",
4545- EnvVars: []string{"ATP_AUTH_FACTOR_TOKEN"},
4646- },
4747- &cli.StringFlag{
4848- Name: "pds-host",
4949- Usage: "URL of the PDS to create account on (overrides DID doc)",
5050- EnvVars: []string{"ATP_PDS_HOST"},
5151- },
5252- },
5353- Action: runAccountLogin,
5454- },
5555- &cli.Command{
5656- Name: "logout",
5757- Usage: "delete any current session",
5858- Action: runAccountLogout,
5959- },
6060- &cli.Command{
6161- Name: "activate",
6262- Usage: "(re)activate current account",
6363- Action: runAccountActivate,
6464- },
6565- &cli.Command{
6666- Name: "deactivate",
6767- Usage: "deactivate current account",
6868- Action: runAccountDeactivate,
6969- },
7070- &cli.Command{
7171- Name: "lookup",
7272- Usage: "show basic account hosting status for any account",
7373- ArgsUsage: `<at-identifier>`,
7474- Action: runAccountLookup,
7575- },
7676- &cli.Command{
7777- Name: "update-handle",
7878- Usage: "change handle for current account",
7979- ArgsUsage: `<handle>`,
8080- Action: runAccountUpdateHandle,
8181- },
8282- &cli.Command{
8383- Name: "status",
8484- Usage: "show current account status at PDS",
8585- Action: runAccountStatus,
8686- },
8787- &cli.Command{
8888- Name: "missing-blobs",
8989- Usage: "list any missing blobs for current account",
9090- Action: runAccountMissingBlobs,
9191- },
9292- &cli.Command{
9393- Name: "service-auth",
9494- Usage: "ask the PDS to create a service auth token",
9595- Flags: []cli.Flag{
9696- &cli.StringFlag{
9797- Name: "endpoint",
9898- Aliases: []string{"lxm"},
9999- Usage: "restrict token to API endpoint (NSID, optional)",
100100- },
101101- &cli.StringFlag{
102102- Name: "audience",
103103- Aliases: []string{"aud"},
104104- Required: true,
105105- Usage: "DID of service that will receive and validate token",
106106- },
107107- &cli.IntFlag{
108108- Name: "duration-sec",
109109- Value: 60,
110110- Usage: "validity time window of token (seconds)",
111111- },
112112- },
113113- Action: runAccountServiceAuth,
114114- },
115115- &cli.Command{
116116- Name: "service-auth-offline",
117117- Usage: "create service auth token via locally-held signing key",
118118- Flags: []cli.Flag{
119119- &cli.StringFlag{
120120- Name: "atproto-signing-key",
121121- Required: true,
122122- Usage: "private key used to sign the token (multibase syntax)",
123123- EnvVars: []string{"ATPROTO_SIGNING_KEY"},
124124- },
125125- &cli.StringFlag{
126126- Name: "iss",
127127- Required: true,
128128- Usage: "the DID of the account issuing the token",
129129- },
130130- &cli.StringFlag{
131131- Name: "endpoint",
132132- Aliases: []string{"lxm"},
133133- Usage: "restrict token to API endpoint (NSID, optional)",
134134- },
135135- &cli.StringFlag{
136136- Name: "audience",
137137- Aliases: []string{"aud"},
138138- Required: true,
139139- Usage: "DID of service that will receive and validate token",
140140- },
141141- &cli.IntFlag{
142142- Name: "duration-sec",
143143- Value: 60,
144144- Usage: "validity time window of token (seconds)",
145145- },
146146- },
147147- Action: runAccountServiceAuthOffline,
148148- },
149149- &cli.Command{
150150- Name: "create",
151151- Usage: "create a new account on the indicated PDS host",
152152- Flags: []cli.Flag{
153153- &cli.StringFlag{
154154- Name: "pds-host",
155155- Usage: "URL of the PDS to create account on",
156156- Required: true,
157157- EnvVars: []string{"ATP_PDS_HOST"},
158158- },
159159- &cli.StringFlag{
160160- Name: "handle",
161161- Usage: "handle for new account",
162162- Required: true,
163163- EnvVars: []string{"ATP_AUTH_HANDLE"},
164164- },
165165- &cli.StringFlag{
166166- Name: "password",
167167- Usage: "initial account password",
168168- Required: true,
169169- EnvVars: []string{"ATP_AUTH_PASSWORD"},
170170- },
171171- &cli.StringFlag{
172172- Name: "invite-code",
173173- Usage: "invite code for account signup",
174174- },
175175- &cli.StringFlag{
176176- Name: "email",
177177- Usage: "email address for new account",
178178- },
179179- &cli.StringFlag{
180180- Name: "existing-did",
181181- Usage: "an existing DID to use (eg, non-PLC DID, or migration)",
182182- },
183183- &cli.StringFlag{
184184- Name: "recovery-key",
185185- Usage: "public cryptographic key (did:key) to add as PLC recovery",
186186- },
187187- &cli.StringFlag{
188188- Name: "service-auth",
189189- Usage: "service auth token (for account migration)",
190190- },
191191- },
192192- Action: runAccountCreate,
193193- },
194194- cmdAccountMigrate,
195195- cmdAccountPlc,
196196- },
197197-}
198198-199199-func runAccountLogin(cctx *cli.Context) error {
200200- ctx := context.Background()
201201-202202- username, err := syntax.ParseAtIdentifier(cctx.String("username"))
203203- if err != nil {
204204- return err
205205- }
206206-207207- _, err = refreshAuthSession(ctx, *username, cctx.String("app-password"), cctx.String("pds-host"), cctx.String("auth-factor-token"))
208208- return err
209209-}
210210-211211-func runAccountLogout(cctx *cli.Context) error {
212212- return wipeAuthSession()
213213-}
214214-215215-func runAccountLookup(cctx *cli.Context) error {
216216- ctx := context.Background()
217217- username := cctx.Args().First()
218218- if username == "" {
219219- return fmt.Errorf("need to provide username as an argument")
220220- }
221221- ident, err := resolveIdent(ctx, username)
222222- if err != nil {
223223- return err
224224- }
225225-226226- // create a new API client to connect to the account's PDS
227227- xrpcc := xrpc.Client{
228228- Host: ident.PDSEndpoint(),
229229- UserAgent: userAgent(),
230230- }
231231- if xrpcc.Host == "" {
232232- return fmt.Errorf("no PDS endpoint for identity")
233233- }
234234-235235- status, err := comatproto.SyncGetRepoStatus(ctx, &xrpcc, ident.DID.String())
236236- if err != nil {
237237- return err
238238- }
239239-240240- fmt.Printf("DID: %s\n", status.Did)
241241- fmt.Printf("Active: %v\n", status.Active)
242242- if status.Status != nil {
243243- fmt.Printf("Status: %s\n", *status.Status)
244244- }
245245- if status.Rev != nil {
246246- fmt.Printf("Repo Rev: %s\n", *status.Rev)
247247- }
248248- return nil
249249-}
250250-251251-func runAccountStatus(cctx *cli.Context) error {
252252- ctx := context.Background()
253253-254254- client, err := loadAuthClient(ctx)
255255- if err == ErrNoAuthSession {
256256- return fmt.Errorf("auth required, but not logged in")
257257- } else if err != nil {
258258- return err
259259- }
260260-261261- status, err := comatproto.ServerCheckAccountStatus(ctx, client)
262262- if err != nil {
263263- return fmt.Errorf("failed checking account status: %w", err)
264264- }
265265-266266- b, err := json.MarshalIndent(status, "", " ")
267267- if err != nil {
268268- return err
269269- }
270270- fmt.Printf("DID: %s\n", client.Auth.Did)
271271- fmt.Printf("Host: %s\n", client.Host)
272272- fmt.Println(string(b))
273273-274274- return nil
275275-}
276276-277277-func runAccountMissingBlobs(cctx *cli.Context) error {
278278- ctx := context.Background()
279279-280280- client, err := loadAuthClient(ctx)
281281- if err == ErrNoAuthSession {
282282- return fmt.Errorf("auth required, but not logged in")
283283- } else if err != nil {
284284- return err
285285- }
286286-287287- cursor := ""
288288- for {
289289- resp, err := comatproto.RepoListMissingBlobs(ctx, client, cursor, 500)
290290- if err != nil {
291291- return err
292292- }
293293- for _, missing := range resp.Blobs {
294294- fmt.Printf("%s\t%s\n", missing.Cid, missing.RecordUri)
295295- }
296296- if resp.Cursor != nil && *resp.Cursor != "" {
297297- cursor = *resp.Cursor
298298- } else {
299299- break
300300- }
301301- }
302302- return nil
303303-}
304304-305305-func runAccountActivate(cctx *cli.Context) error {
306306- ctx := context.Background()
307307-308308- client, err := loadAuthClient(ctx)
309309- if err == ErrNoAuthSession {
310310- return fmt.Errorf("auth required, but not logged in")
311311- } else if err != nil {
312312- return err
313313- }
314314-315315- err = comatproto.ServerActivateAccount(ctx, client)
316316- if err != nil {
317317- return fmt.Errorf("failed activating account: %w", err)
318318- }
319319-320320- return nil
321321-}
322322-323323-func runAccountDeactivate(cctx *cli.Context) error {
324324- ctx := context.Background()
325325-326326- client, err := loadAuthClient(ctx)
327327- if err == ErrNoAuthSession {
328328- return fmt.Errorf("auth required, but not logged in")
329329- } else if err != nil {
330330- return err
331331- }
332332-333333- err = comatproto.ServerDeactivateAccount(ctx, client, &comatproto.ServerDeactivateAccount_Input{})
334334- if err != nil {
335335- return fmt.Errorf("failed deactivating account: %w", err)
336336- }
337337-338338- return nil
339339-}
340340-341341-func runAccountUpdateHandle(cctx *cli.Context) error {
342342- ctx := context.Background()
343343-344344- raw := cctx.Args().First()
345345- if raw == "" {
346346- return fmt.Errorf("need to provide new handle as argument")
347347- }
348348- handle, err := syntax.ParseHandle(raw)
349349- if err != nil {
350350- return err
351351- }
352352-353353- client, err := loadAuthClient(ctx)
354354- if err == ErrNoAuthSession {
355355- return fmt.Errorf("auth required, but not logged in")
356356- } else if err != nil {
357357- return err
358358- }
359359-360360- err = comatproto.IdentityUpdateHandle(ctx, client, &comatproto.IdentityUpdateHandle_Input{
361361- Handle: handle.String(),
362362- })
363363- if err != nil {
364364- return fmt.Errorf("failed updating handle: %w", err)
365365- }
366366-367367- return nil
368368-}
369369-370370-func runAccountServiceAuth(cctx *cli.Context) error {
371371- ctx := context.Background()
372372-373373- client, err := loadAuthClient(ctx)
374374- if err == ErrNoAuthSession {
375375- return fmt.Errorf("auth required, but not logged in")
376376- } else if err != nil {
377377- return err
378378- }
379379-380380- lxm := cctx.String("endpoint")
381381- if lxm != "" {
382382- _, err := syntax.ParseNSID(lxm)
383383- if err != nil {
384384- return fmt.Errorf("lxm argument must be a valid NSID: %w", err)
385385- }
386386- }
387387-388388- aud := cctx.String("audience")
389389- // TODO: can aud DID have a fragment?
390390- _, err = syntax.ParseDID(aud)
391391- if err != nil {
392392- return fmt.Errorf("aud argument must be a valid DID: %w", err)
393393- }
394394-395395- durSec := cctx.Int("duration-sec")
396396- expTimestamp := time.Now().Unix() + int64(durSec)
397397-398398- resp, err := comatproto.ServerGetServiceAuth(ctx, client, aud, expTimestamp, lxm)
399399- if err != nil {
400400- return fmt.Errorf("failed updating handle: %w", err)
401401- }
402402-403403- fmt.Println(resp.Token)
404404-405405- return nil
406406-}
407407-408408-func runAccountServiceAuthOffline(cctx *cli.Context) error {
409409- privStr := cctx.String("atproto-signing-key")
410410- if privStr == "" {
411411- return fmt.Errorf("private key must be provided")
412412- }
413413- privkey, err := crypto.ParsePrivateMultibase(privStr)
414414- if err != nil {
415415- return fmt.Errorf("failed parsing private key: %w", err)
416416- }
417417-418418- issString := cctx.String("iss")
419419- // TODO: support fragment identifiers
420420- iss, err := syntax.ParseDID(issString)
421421- if err != nil {
422422- return fmt.Errorf("iss argument must be a valid DID: %w", err)
423423- }
424424-425425- lxmString := cctx.String("endpoint")
426426- var lxm *syntax.NSID = nil
427427- if lxmString != "" {
428428- lxmTmp, err := syntax.ParseNSID(lxmString)
429429- if err != nil {
430430- return fmt.Errorf("lxm argument must be a valid NSID: %w", err)
431431- }
432432- lxm = &lxmTmp
433433- }
434434-435435- aud := cctx.String("audience")
436436- // TODO: can aud DID have a fragment?
437437- _, err = syntax.ParseDID(aud)
438438- if err != nil {
439439- return fmt.Errorf("aud argument must be a valid DID: %w", err)
440440- }
441441-442442- durSec := cctx.Int("duration-sec")
443443- duration := time.Duration(durSec * int(time.Second))
444444-445445- token, err := auth.SignServiceAuth(iss, aud, duration, lxm, privkey)
446446- if err != nil {
447447- return fmt.Errorf("failed signing token: %w", err)
448448- }
449449-450450- fmt.Println(token)
451451-452452- return nil
453453-}
454454-455455-func runAccountCreate(cctx *cli.Context) error {
456456- ctx := context.Background()
457457-458458- // validate args
459459- pdsHost := cctx.String("pds-host")
460460- if !strings.Contains(pdsHost, "://") {
461461- return fmt.Errorf("PDS host is not a url: %s", pdsHost)
462462- }
463463- handle := cctx.String("handle")
464464- _, err := syntax.ParseHandle(handle)
465465- if err != nil {
466466- return err
467467- }
468468- password := cctx.String("password")
469469- params := &comatproto.ServerCreateAccount_Input{
470470- Handle: handle,
471471- Password: &password,
472472- }
473473- raw := cctx.String("existing-did")
474474- if raw != "" {
475475- _, err := syntax.ParseDID(raw)
476476- if err != nil {
477477- return err
478478- }
479479- s := raw
480480- params.Did = &s
481481- }
482482- raw = cctx.String("email")
483483- if raw != "" {
484484- s := raw
485485- params.Email = &s
486486- }
487487- raw = cctx.String("invite-code")
488488- if raw != "" {
489489- s := raw
490490- params.InviteCode = &s
491491- }
492492- raw = cctx.String("recovery-key")
493493- if raw != "" {
494494- s := raw
495495- params.RecoveryKey = &s
496496- }
497497-498498- // create a new API client to connect to the account's PDS
499499- xrpcc := xrpc.Client{
500500- Host: pdsHost,
501501- UserAgent: userAgent(),
502502- }
503503-504504- raw = cctx.String("service-auth")
505505- if raw != "" && params.Did != nil {
506506- xrpcc.Auth = &xrpc.AuthInfo{
507507- Did: *params.Did,
508508- AccessJwt: raw,
509509- }
510510- }
511511-512512- resp, err := comatproto.ServerCreateAccount(ctx, &xrpcc, params)
513513- if err != nil {
514514- return fmt.Errorf("failed to create account: %w", err)
515515- }
516516-517517- fmt.Println("Success!")
518518- fmt.Printf("DID: %s\n", resp.Did)
519519- fmt.Printf("Handle: %s\n", resp.Handle)
520520- return nil
521521-}
-262
cmd/goat/account_migrate.go
···11-package main
22-33-import (
44- "bytes"
55- "context"
66- "encoding/json"
77- "fmt"
88- "log/slog"
99- "strings"
1010- "time"
1111-1212- "github.com/bluesky-social/indigo/api/agnostic"
1313- comatproto "github.com/bluesky-social/indigo/api/atproto"
1414- "github.com/bluesky-social/indigo/atproto/syntax"
1515- "github.com/bluesky-social/indigo/xrpc"
1616-1717- "github.com/urfave/cli/v2"
1818-)
1919-2020-var cmdAccountMigrate = &cli.Command{
2121- Name: "migrate",
2222- Usage: "move account to a new PDS. requires full auth.",
2323- Flags: []cli.Flag{
2424- &cli.StringFlag{
2525- Name: "pds-host",
2626- Usage: "URL of the new PDS to create account on",
2727- Required: true,
2828- EnvVars: []string{"ATP_PDS_HOST"},
2929- },
3030- &cli.StringFlag{
3131- Name: "new-handle",
3232- Required: true,
3333- Usage: "handle on new PDS",
3434- EnvVars: []string{"NEW_ACCOUNT_HANDLE"},
3535- },
3636- &cli.StringFlag{
3737- Name: "new-password",
3838- Required: true,
3939- Usage: "password on new PDS",
4040- EnvVars: []string{"NEW_ACCOUNT_PASSWORD"},
4141- },
4242- &cli.StringFlag{
4343- Name: "plc-token",
4444- Required: true,
4545- Usage: "token from old PDS authorizing token signature",
4646- EnvVars: []string{"PLC_SIGN_TOKEN"},
4747- },
4848- &cli.StringFlag{
4949- Name: "invite-code",
5050- Usage: "invite code for account signup",
5151- },
5252- &cli.StringFlag{
5353- Name: "new-email",
5454- Usage: "email address for new account",
5555- },
5656- },
5757- Action: runAccountMigrate,
5858-}
5959-6060-func runAccountMigrate(cctx *cli.Context) error {
6161- // NOTE: this could check rev / commit before and after and ensure last-minute content additions get lost
6262- ctx := context.Background()
6363-6464- oldClient, err := loadAuthClient(ctx)
6565- if err == ErrNoAuthSession {
6666- return fmt.Errorf("auth required, but not logged in")
6767- } else if err != nil {
6868- return err
6969- }
7070- did := oldClient.Auth.Did
7171-7272- newHostURL := cctx.String("pds-host")
7373- if !strings.Contains(newHostURL, "://") {
7474- return fmt.Errorf("PDS host is not a url: %s", newHostURL)
7575- }
7676- newHandle := cctx.String("new-handle")
7777- _, err = syntax.ParseHandle(newHandle)
7878- if err != nil {
7979- return err
8080- }
8181- newPassword := cctx.String("new-password")
8282- plcToken := cctx.String("plc-token")
8383- inviteCode := cctx.String("invite-code")
8484- newEmail := cctx.String("new-email")
8585-8686- newClient := xrpc.Client{
8787- Host: newHostURL,
8888- UserAgent: userAgent(),
8989- }
9090-9191- // connect to new host to discover service DID
9292- newHostDesc, err := comatproto.ServerDescribeServer(ctx, &newClient)
9393- if err != nil {
9494- return fmt.Errorf("failed connecting to new host: %w", err)
9595- }
9696- newHostDID, err := syntax.ParseDID(newHostDesc.Did)
9797- if err != nil {
9898- return err
9999- }
100100- slog.Info("new host", "serviceDID", newHostDID, "url", newHostURL)
101101-102102- // 1. Create New Account
103103- slog.Info("creating account on new host", "handle", newHandle, "host", newHostURL)
104104-105105- // get service auth token from old host
106106- // args: (ctx, client, aud string, exp int64, lxm string)
107107- expTimestamp := time.Now().Unix() + 60
108108- createAuthResp, err := comatproto.ServerGetServiceAuth(ctx, oldClient, newHostDID.String(), expTimestamp, "com.atproto.server.createAccount")
109109- if err != nil {
110110- return fmt.Errorf("failed getting service auth token from old host: %w", err)
111111- }
112112-113113- // then create the new account
114114- createParams := comatproto.ServerCreateAccount_Input{
115115- Did: &did,
116116- Handle: newHandle,
117117- Password: &newPassword,
118118- }
119119- if newEmail != "" {
120120- createParams.Email = &newEmail
121121- }
122122- if inviteCode != "" {
123123- createParams.InviteCode = &inviteCode
124124- }
125125-126126- // use service auth for access token, temporarily
127127- newClient.Auth = &xrpc.AuthInfo{
128128- Did: did,
129129- Handle: newHandle,
130130- AccessJwt: createAuthResp.Token,
131131- RefreshJwt: createAuthResp.Token,
132132- }
133133- createAccountResp, err := comatproto.ServerCreateAccount(ctx, &newClient, &createParams)
134134- if err != nil {
135135- return fmt.Errorf("failed creating new account: %w", err)
136136- }
137137-138138- if createAccountResp.Did != did {
139139- return fmt.Errorf("new account DID not a match: %s != %s", createAccountResp.Did, did)
140140- }
141141- newClient.Auth.AccessJwt = createAccountResp.AccessJwt
142142- newClient.Auth.RefreshJwt = createAccountResp.RefreshJwt
143143-144144- // login client on the new host
145145- sess, err := comatproto.ServerCreateSession(ctx, &newClient, &comatproto.ServerCreateSession_Input{
146146- Identifier: did,
147147- Password: newPassword,
148148- })
149149- if err != nil {
150150- return fmt.Errorf("failed login to newly created account on new host: %w", err)
151151- }
152152- newClient.Auth = &xrpc.AuthInfo{
153153- Did: did,
154154- AccessJwt: sess.AccessJwt,
155155- RefreshJwt: sess.RefreshJwt,
156156- }
157157-158158- // 2. Migrate Data
159159- slog.Info("migrating repo")
160160- repoBytes, err := comatproto.SyncGetRepo(ctx, oldClient, did, "")
161161- if err != nil {
162162- return fmt.Errorf("failed exporting repo: %w", err)
163163- }
164164- err = comatproto.RepoImportRepo(ctx, &newClient, bytes.NewReader(repoBytes))
165165- if err != nil {
166166- return fmt.Errorf("failed importing repo: %w", err)
167167- }
168168-169169- slog.Info("migrating preferences")
170170- // TODO: service proxy header for AppView?
171171- prefResp, err := agnostic.ActorGetPreferences(ctx, oldClient)
172172- if err != nil {
173173- return fmt.Errorf("failed fetching old preferences: %w", err)
174174- }
175175- err = agnostic.ActorPutPreferences(ctx, &newClient, &agnostic.ActorPutPreferences_Input{
176176- Preferences: prefResp.Preferences,
177177- })
178178- if err != nil {
179179- return fmt.Errorf("failed importing preferences: %w", err)
180180- }
181181-182182- slog.Info("migrating blobs")
183183- blobCursor := ""
184184- for {
185185- listResp, err := comatproto.SyncListBlobs(ctx, oldClient, blobCursor, did, 100, "")
186186- if err != nil {
187187- return fmt.Errorf("failed listing blobs: %w", err)
188188- }
189189- for _, blobCID := range listResp.Cids {
190190- blobBytes, err := comatproto.SyncGetBlob(ctx, oldClient, blobCID, did)
191191- if err != nil {
192192- slog.Warn("failed downloading blob", "cid", blobCID, "err", err)
193193- continue
194194- }
195195- _, err = comatproto.RepoUploadBlob(ctx, &newClient, bytes.NewReader(blobBytes))
196196- if err != nil {
197197- slog.Warn("failed uploading blob", "cid", blobCID, "err", err, "size", len(blobBytes))
198198- }
199199- slog.Info("transferred blob", "cid", blobCID, "size", len(blobBytes))
200200- }
201201- if listResp.Cursor == nil || *listResp.Cursor == "" {
202202- break
203203- }
204204- blobCursor = *listResp.Cursor
205205- }
206206-207207- // display migration status
208208- // NOTE: this could check between the old PDS and new PDS, polling in a loop showing progress until all records have been indexed
209209- statusResp, err := comatproto.ServerCheckAccountStatus(ctx, &newClient)
210210- if err != nil {
211211- return fmt.Errorf("failed checking account status: %w", err)
212212- }
213213- slog.Info("account migration status", "status", statusResp)
214214-215215- // 3. Migrate Identity
216216- // NOTE: to work with did:web or non-PDS-managed did:plc, need to do manual migraiton process
217217- slog.Info("updating identity to new host")
218218-219219- credsResp, err := agnostic.IdentityGetRecommendedDidCredentials(ctx, &newClient)
220220- if err != nil {
221221- return fmt.Errorf("failed fetching new credentials: %w", err)
222222- }
223223- credsBytes, err := json.Marshal(credsResp)
224224- if err != nil {
225225- return nil
226226- }
227227-228228- var unsignedOp agnostic.IdentitySignPlcOperation_Input
229229- if err = json.Unmarshal(credsBytes, &unsignedOp); err != nil {
230230- return fmt.Errorf("failed parsing PLC op: %w", err)
231231- }
232232- unsignedOp.Token = &plcToken
233233-234234- // NOTE: could add additional sanity checks here that any extra rotation keys were retained, and that old alsoKnownAs and service entries are retained? The stakes aren't super high for the later, as PLC has the full history. PLC and the new PDS already implement some basic sanity checks.
235235-236236- signedPlcOpResp, err := agnostic.IdentitySignPlcOperation(ctx, oldClient, &unsignedOp)
237237- if err != nil {
238238- return fmt.Errorf("failed requesting PLC operation signature: %w", err)
239239- }
240240-241241- err = agnostic.IdentitySubmitPlcOperation(ctx, &newClient, &agnostic.IdentitySubmitPlcOperation_Input{
242242- Operation: signedPlcOpResp.Operation,
243243- })
244244- if err != nil {
245245- return fmt.Errorf("failed submitting PLC operation: %w", err)
246246- }
247247-248248- // 4. Finalize Migration
249249- slog.Info("activating new account")
250250-251251- err = comatproto.ServerActivateAccount(ctx, &newClient)
252252- if err != nil {
253253- return fmt.Errorf("failed activating new host: %w", err)
254254- }
255255- err = comatproto.ServerDeactivateAccount(ctx, oldClient, &comatproto.ServerDeactivateAccount_Input{})
256256- if err != nil {
257257- return fmt.Errorf("failed deactivating old host: %w", err)
258258- }
259259-260260- slog.Info("account migration completed")
261261- return nil
262262-}
-328
cmd/goat/account_plc.go
···11-package main
22-33-import (
44- "context"
55- "encoding/json"
66- "fmt"
77- "os"
88- "slices"
99-1010- "github.com/bluesky-social/indigo/api/agnostic"
1111- comatproto "github.com/bluesky-social/indigo/api/atproto"
1212- "github.com/bluesky-social/indigo/atproto/crypto"
1313- "github.com/bluesky-social/indigo/atproto/syntax"
1414- "github.com/did-method-plc/go-didplc"
1515-1616- "github.com/urfave/cli/v2"
1717-)
1818-1919-var cmdAccountPlc = &cli.Command{
2020- Name: "plc",
2121- Usage: "sub-commands for managing PLC DID via PDS host",
2222- Flags: []cli.Flag{
2323- &cli.StringFlag{
2424- Name: "plc-host",
2525- Usage: "method, hostname, and port of PLC registry",
2626- Value: "https://plc.directory",
2727- EnvVars: []string{"ATP_PLC_HOST"},
2828- },
2929- },
3030- Subcommands: []*cli.Command{
3131- &cli.Command{
3232- Name: "recommended",
3333- Usage: "list recommended DID fields for current account",
3434- Action: runAccountPlcRecommended,
3535- },
3636- &cli.Command{
3737- Name: "request-token",
3838- Usage: "request a 2FA token (by email) for signing op",
3939- Action: runAccountPlcRequestToken,
4040- },
4141- &cli.Command{
4242- Name: "sign",
4343- Usage: "sign a PLC operation",
4444- ArgsUsage: `<json-file>`,
4545- Action: runAccountPlcSign,
4646- Flags: []cli.Flag{
4747- &cli.StringFlag{
4848- Name: "token",
4949- Usage: "2FA token for PLC operation signing request",
5050- },
5151- },
5252- },
5353- &cli.Command{
5454- Name: "submit",
5555- Usage: "submit a PLC operation (via PDS)",
5656- ArgsUsage: `<json-file>`,
5757- Action: runAccountPlcSubmit,
5858- },
5959- &cli.Command{
6060- Name: "current",
6161- Usage: "print current PLC data for account (fetched from directory)",
6262- Action: runAccountPlcCurrent,
6363- },
6464- &cli.Command{
6565- Name: "add-rotation-key",
6666- Usage: "add a new rotation key to PLC identity (via PDS)",
6767- ArgsUsage: `<pubkey>`,
6868- Action: runAccountPlcAddRotationKey,
6969- Flags: []cli.Flag{
7070- &cli.StringFlag{
7171- Name: "token",
7272- Usage: "2FA token for PLC operation signing request",
7373- },
7474- &cli.BoolFlag{
7575- Name: "first",
7676- Usage: "inserts key at the top of key list (highest priority)",
7777- },
7878- },
7979- },
8080- },
8181-}
8282-8383-func runAccountPlcRecommended(cctx *cli.Context) error {
8484- ctx := context.Background()
8585-8686- xrpcc, err := loadAuthClient(ctx)
8787- if err == ErrNoAuthSession {
8888- return fmt.Errorf("auth required, but not logged in")
8989- } else if err != nil {
9090- return err
9191- }
9292-9393- resp, err := agnostic.IdentityGetRecommendedDidCredentials(ctx, xrpcc)
9494- if err != nil {
9595- return err
9696- }
9797-9898- b, err := json.MarshalIndent(resp, "", " ")
9999- if err != nil {
100100- return err
101101- }
102102-103103- fmt.Println(string(b))
104104- return nil
105105-}
106106-107107-func runAccountPlcRequestToken(cctx *cli.Context) error {
108108- ctx := context.Background()
109109-110110- xrpcc, err := loadAuthClient(ctx)
111111- if err == ErrNoAuthSession {
112112- return fmt.Errorf("auth required, but not logged in")
113113- } else if err != nil {
114114- return err
115115- }
116116-117117- err = comatproto.IdentityRequestPlcOperationSignature(ctx, xrpcc)
118118- if err != nil {
119119- return err
120120- }
121121-122122- fmt.Println("Success; check email for token.")
123123- return nil
124124-}
125125-126126-func runAccountPlcSign(cctx *cli.Context) error {
127127- ctx := context.Background()
128128-129129- opPath := cctx.Args().First()
130130- if opPath == "" {
131131- return fmt.Errorf("need to provide JSON file path as an argument")
132132- }
133133-134134- xrpcc, err := loadAuthClient(ctx)
135135- if err == ErrNoAuthSession {
136136- return fmt.Errorf("auth required, but not logged in")
137137- } else if err != nil {
138138- return err
139139- }
140140-141141- fileBytes, err := os.ReadFile(opPath)
142142- if err != nil {
143143- return err
144144- }
145145-146146- var body agnostic.IdentitySignPlcOperation_Input
147147- if err = json.Unmarshal(fileBytes, &body); err != nil {
148148- return fmt.Errorf("failed decoding PLC op JSON: %w", err)
149149- }
150150-151151- token := cctx.String("token")
152152- if token != "" {
153153- body.Token = &token
154154- }
155155-156156- resp, err := agnostic.IdentitySignPlcOperation(ctx, xrpcc, &body)
157157- if err != nil {
158158- return err
159159- }
160160-161161- b, err := json.MarshalIndent(resp.Operation, "", " ")
162162- if err != nil {
163163- return err
164164- }
165165-166166- fmt.Println(string(b))
167167- return nil
168168-}
169169-170170-func runAccountPlcSubmit(cctx *cli.Context) error {
171171- ctx := context.Background()
172172-173173- opPath := cctx.Args().First()
174174- if opPath == "" {
175175- return fmt.Errorf("need to provide JSON file path as an argument")
176176- }
177177-178178- xrpcc, err := loadAuthClient(ctx)
179179- if err == ErrNoAuthSession {
180180- return fmt.Errorf("auth required, but not logged in")
181181- } else if err != nil {
182182- return err
183183- }
184184-185185- fileBytes, err := os.ReadFile(opPath)
186186- if err != nil {
187187- return err
188188- }
189189-190190- var opEnum didplc.OpEnum
191191- if err = json.Unmarshal(fileBytes, &opEnum); err != nil {
192192- return fmt.Errorf("failed decoding PLC op JSON: %w", err)
193193- }
194194- op := opEnum.AsOperation()
195195-196196- if op.IsGenesis() {
197197- return fmt.Errorf("can't submit a genesis operation via PDS (HINT: Make sure the prev field is set, or try `goat plc submit --genesis`)")
198198- }
199199-200200- if !op.IsSigned() {
201201- return fmt.Errorf("operation must be signed (HINT: try `goat account plc sign`)")
202202- }
203203-204204- // convert it back to JSON for submission
205205- opEncoded, err := json.Marshal(op)
206206- if err != nil {
207207- return err
208208- }
209209- rawMsg := json.RawMessage(opEncoded)
210210- err = agnostic.IdentitySubmitPlcOperation(ctx, xrpcc, &agnostic.IdentitySubmitPlcOperation_Input{
211211- Operation: &rawMsg,
212212- })
213213-214214- if err != nil {
215215- return fmt.Errorf("failed submitting PLC op via PDS: %w", err)
216216- }
217217-218218- return nil
219219-}
220220-221221-func runAccountPlcCurrent(cctx *cli.Context) error {
222222- ctx := context.Background()
223223-224224- xrpcc, err := loadAuthClient(ctx)
225225- if err == ErrNoAuthSession || xrpcc.Auth == nil {
226226- return fmt.Errorf("auth required, but not logged in")
227227- } else if err != nil {
228228- return err
229229- }
230230-231231- did, err := syntax.ParseDID(xrpcc.Auth.Did)
232232- if err != nil {
233233- return err
234234- }
235235-236236- plcData, err := fetchPLCData(ctx, cctx.String("plc-host"), did)
237237- if err != nil {
238238- return err
239239- }
240240-241241- b, err := json.MarshalIndent(plcData, "", " ")
242242- if err != nil {
243243- return err
244244- }
245245- fmt.Println(string(b))
246246- return nil
247247-}
248248-249249-func runAccountPlcAddRotationKey(cctx *cli.Context) error {
250250- ctx := context.Background()
251251-252252- newKeyStr := cctx.Args().First()
253253- if newKeyStr == "" {
254254- return fmt.Errorf("need to provide public key argument (as did:key)")
255255- }
256256-257257- // check that it is a valid pubkey
258258- _, err := crypto.ParsePublicDIDKey(newKeyStr)
259259- if err != nil {
260260- return err
261261- }
262262-263263- xrpcc, err := loadAuthClient(ctx)
264264- if err == ErrNoAuthSession {
265265- return fmt.Errorf("auth required, but not logged in")
266266- } else if err != nil {
267267- return err
268268- }
269269-270270- did, err := syntax.ParseDID(xrpcc.Auth.Did)
271271- if err != nil {
272272- return err
273273- }
274274-275275- // 1. fetch current PLC op: plc.directory/{did}/data
276276- plcData, err := fetchPLCData(ctx, cctx.String("plc-host"), did)
277277- if err != nil {
278278- return err
279279- }
280280-281281- if len(plcData.RotationKeys) >= 5 {
282282- fmt.Println("WARNGING: already have 5 rotation keys, which is the maximum")
283283- }
284284-285285- for _, k := range plcData.RotationKeys {
286286- if k == newKeyStr {
287287- return fmt.Errorf("key already registered as a rotation key")
288288- }
289289- }
290290-291291- // 2. update data
292292- if cctx.Bool("first") {
293293- plcData.RotationKeys = slices.Insert(plcData.RotationKeys, 0, newKeyStr)
294294- } else {
295295- plcData.RotationKeys = append(plcData.RotationKeys, newKeyStr)
296296- }
297297-298298- // 3. get data signed (using token)
299299- opBytes, err := json.Marshal(&plcData)
300300- if err != nil {
301301- return err
302302- }
303303- var body agnostic.IdentitySignPlcOperation_Input
304304- if err = json.Unmarshal(opBytes, &body); err != nil {
305305- return fmt.Errorf("failed decoding PLC op JSON: %w", err)
306306- }
307307-308308- token := cctx.String("token")
309309- if token != "" {
310310- body.Token = &token
311311- }
312312-313313- resp, err := agnostic.IdentitySignPlcOperation(ctx, xrpcc, &body)
314314- if err != nil {
315315- return err
316316- }
317317-318318- // 4. submit signed op
319319- err = agnostic.IdentitySubmitPlcOperation(ctx, xrpcc, &agnostic.IdentitySubmitPlcOperation_Input{
320320- Operation: resp.Operation,
321321- })
322322- if err != nil {
323323- return fmt.Errorf("failed submitting PLC op via PDS: %w", err)
324324- }
325325-326326- fmt.Println("Success!")
327327- return nil
328328-}
···66666767 http post :2470/admin/pds/requestCrawl -a admin:dummy hostname=pds.example.com
68686969-The `goat` command line tool (also part of the indigo git repository) includes helpers for administering, inspecting, and debugging relays:
6969+The `goat` command line tool includes helpers for administering, inspecting, and debugging relays:
70707171 RELAY_HOST=http://localhost:2470 goat firehose --verify-mst
7272 RELAY_HOST=http://localhost:2470 goat relay admin host list