From cdf23dcae76dd9fe35bcc8f6f59d5b3296157856 Mon Sep 17 00:00:00 2001 From: dawn Date: Wed, 21 Oct 2026 00:21:27 +0300 Subject: [PATCH] spindle: manage the allow-list and mint invite codes from the cli Signed-off-by: dawn --- cmd/spindle/main.go | 117 ++++++++++++++++-- cmd/spindle/main_test.go | 69 +++++++++++ .../content/docs/spindles/architecture.mdx | 3 + .../src/content/docs/spindles/self-hosting.md | 17 ++- spindle/db/invites.go | 21 +++- spindle/xrpc/invites.go | 18 +-- spindle/xrpc/invites_test.go | 9 +- 7 files changed, 217 insertions(+), 37 deletions(-) diff --git a/cmd/spindle/main.go b/cmd/spindle/main.go index 1d1eb9df5..6c5029ad2 100644 --- a/cmd/spindle/main.go +++ b/cmd/spindle/main.go @@ -17,6 +17,7 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "github.com/urfave/cli/v3" + "tangled.org/core/idresolver" tlog "tangled.org/core/log" "tangled.org/core/spindle" "tangled.org/core/spindle/config" @@ -37,6 +38,8 @@ func main() { banCommand(), unbanCommand(), bansCommand(), + allowListCommand(), + inviteCommand(), }, DefaultCommand: "run", } @@ -564,11 +567,11 @@ func formatLimit(resource string, limit int64) string { func banCommand() *cli.Command { return &cli.Command{ Name: "ban", - ArgsUsage: "", + ArgsUsage: "", Usage: "ban a repo or user and wipe their existing data on next startup", Flags: []cli.Flag{dbFlag}, Action: func(ctx context.Context, cmd *cli.Command) error { - did, err := banArg(cmd) + did, err := subjectArg(ctx, cmd) if err != nil { return err } @@ -585,11 +588,11 @@ func banCommand() *cli.Command { func unbanCommand() *cli.Command { return &cli.Command{ Name: "unban", - ArgsUsage: "", + ArgsUsage: "", Usage: "unban a repo or user", Flags: []cli.Flag{dbFlag}, Action: func(ctx context.Context, cmd *cli.Command) error { - did, err := banArg(cmd) + did, err := subjectArg(ctx, cmd) if err != nil { return err } @@ -635,13 +638,109 @@ func bansCommand() *cli.Command { } } -func banArg(cmd *cli.Command) (syntax.DID, error) { +// writes the db directly, so a running spindle admits the change at once but +// only backfills the person's existing repos on its next start +func allowListCommand() *cli.Command { + return &cli.Command{ + Name: "allowlist", + Usage: "manage who this spindle runs ci for while admission is closed", + Commands: []*cli.Command{ + { + Name: "add", + ArgsUsage: "", + Usage: "let a user run ci on this spindle", + Flags: []cli.Flag{dbFlag}, + Action: func(ctx context.Context, cmd *cli.Command) error { + return withDid(ctx, cmd, func(d *db.DB, did syntax.DID) error { + return db.AddToAllowlist(d, did) + }) + }, + }, + { + Name: "remove", + ArgsUsage: "", + Usage: "take a user off the allow-list", + Flags: []cli.Flag{dbFlag}, + Action: func(ctx context.Context, cmd *cli.Command) error { + return withDid(ctx, cmd, func(d *db.DB, did syntax.DID) error { + return db.RemoveFromAllowlist(d, did) + }) + }, + }, + { + Name: "list", + Usage: "list the allow-list", + Flags: []cli.Flag{dbFlag}, + Action: func(ctx context.Context, cmd *cli.Command) error { + d, err := openDB(ctx, cmd) + if err != nil { + return err + } + defer d.Close() + dids, err := db.ListAllowlist(d) + if err != nil { + return err + } + for _, did := range dids { + fmt.Println(did) + } + return nil + }, + }, + }, + } +} + +func inviteCommand() *cli.Command { + return &cli.Command{ + Name: "invite", + Usage: "mint a single use invite code that puts whoever redeems it on the allow-list", + Flags: []cli.Flag{dbFlag}, + Action: func(ctx context.Context, cmd *cli.Command) error { + d, err := openDB(ctx, cmd) + if err != nil { + return err + } + defer d.Close() + code, expires, err := d.MintInvite("cli", time.Now()) + if err != nil { + return err + } + fmt.Printf("%s (expires %s)\n", code, expires.UTC().Format(time.RFC3339)) + return nil + }, + } +} + +func withDid(ctx context.Context, cmd *cli.Command, fn func(d *db.DB, did syntax.DID) error) error { + did, err := subjectArg(ctx, cmd) + if err != nil { + return err + } + d, err := openDB(ctx, cmd) + if err != nil { + return err + } + defer d.Close() + return fn(d, did) +} + +// a did is taken as is, so banning a repo did never needs the network +func subjectArg(ctx context.Context, cmd *cli.Command) (syntax.DID, error) { if cmd.Args().Len() != 1 { - return "", fmt.Errorf("expected ") + return "", fmt.Errorf("expected ") + } + arg := cmd.Args().Get(0) + if did, err := syntax.ParseDID(arg); err == nil { + return did, nil + } + plc := os.Getenv("SPINDLE_SERVER_PLC_URL") + if plc == "" { + plc = "https://plc.directory" } - did, err := syntax.ParseDID(cmd.Args().Get(0)) + ident, err := idresolver.DefaultResolver(plc).ResolveAtIdentifier(ctx, arg) if err != nil { - return "", fmt.Errorf("invalid did: %w", err) + return "", err } - return did, nil + return ident.DID, nil } diff --git a/cmd/spindle/main_test.go b/cmd/spindle/main_test.go index 739b4e163..a4d043976 100644 --- a/cmd/spindle/main_test.go +++ b/cmd/spindle/main_test.go @@ -5,9 +5,12 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "testing" + "time" + "tangled.org/core/spindle/db" "tangled.org/core/spindle/quota" ) @@ -200,6 +203,72 @@ func TestCLIQuotaCommands(t *testing.T) { } } +func runCLI(t *testing.T, dbPath string, args ...string) (string, int) { + t.Helper() + cmd := exec.Command(os.Args[0], append(append([]string{"-test.run=TestHelperProcess", "--"}, args...), "--db", dbPath)...) + cmd.Env = append(os.Environ(), "WANT_HELPER_PROCESS=1") + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + err := cmd.Run() + if exitErr, ok := err.(*exec.ExitError); ok { + return out.String(), exitErr.ExitCode() + } else if err != nil { + t.Fatalf("running %v: %v", args, err) + } + return out.String(), 0 +} + +func TestCLIAllowListCommands(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "spindle.db") + listed := func() string { + out, code := runCLI(t, dbPath, "allowlist", "list") + if code != 0 { + t.Fatalf("allowlist list = %d: %s", code, out) + } + return out + } + + if out, code := runCLI(t, dbPath, "allowlist", "add", "alice"); code == 0 || !strings.Contains(out, "not a did or handle") { + t.Fatalf("adding something that's neither a did nor a handle = %d: %s", code, out) + } + if out, code := runCLI(t, dbPath, "allowlist", "add", "did:plc:alice"); code != 0 { + t.Fatalf("allowlist add = %d: %s", code, out) + } + if out := listed(); !strings.Contains(out, "did:plc:alice") { + t.Fatalf("alice isn't listed after add: %q", out) + } + if out, code := runCLI(t, dbPath, "allowlist", "remove", "did:plc:alice"); code != 0 { + t.Fatalf("allowlist remove = %d: %s", code, out) + } + if out := listed(); strings.Contains(out, "did:plc:alice") { + t.Fatalf("alice is still listed after remove: %q", out) + } +} + +func TestCLIInviteMintsARedeemableCode(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "spindle.db") + out, code := runCLI(t, dbPath, "invite") + if code != 0 { + t.Fatalf("invite = %d: %s", code, out) + } + // migrations log to the same output, so pick the code out + invite := regexp.MustCompile(`\b[0-9a-f]{32}\b`).FindString(out) + + d, err := db.Make(t.Context(), dbPath) + if err != nil { + t.Fatal(err) + } + defer d.Close() + if err := d.RedeemInvite(invite, "did:plc:bob", time.Now()); err != nil { + t.Fatalf("redeeming the cli's code: %v", err) + } + listed, err := db.AllowlistContains(d, "did:plc:bob") + if err != nil || !listed { + t.Fatalf("bob listed = %v, %v after redeeming", listed, err) + } +} + func TestNormalizeDefaultLimit(t *testing.T) { if got := normalizeDefaultLimit(0); got != -1 { t.Fatalf("zero configured limit = %d, want -1", got) diff --git a/docs/src/content/docs/spindles/architecture.mdx b/docs/src/content/docs/spindles/architecture.mdx index a614a543b..c3c4c710b 100644 --- a/docs/src/content/docs/spindles/architecture.mdx +++ b/docs/src/content/docs/spindles/architecture.mdx @@ -146,6 +146,9 @@ There are two separate caches: `org.tangled.temp.spindle.quota.*`. - **Bans** with `spindle ban`, `unban` and `bans`. A ban wipes the repo's state on the spindle. +- **Allow-list and invites** with `spindle allowlist add|remove|list` and + `spindle invite`, or `org.tangled.temp.spindle.allowList.*` and + `org.tangled.temp.server.{create,redeem}InviteCode`. - **Webhooks** for push, `pull_request:*` and `repository:renamed` are stored and delivered by the spindle (`org.tangled.temp.webhook.*`), with an SSRF-safe client. See [Webhooks](/spindles/webhooks/). diff --git a/docs/src/content/docs/spindles/self-hosting.md b/docs/src/content/docs/spindles/self-hosting.md index e37f0d686..42febe003 100644 --- a/docs/src/content/docs/spindles/self-hosting.md +++ b/docs/src/content/docs/spindles/self-hosting.md @@ -159,8 +159,19 @@ everyone" any more: if you ran a spindle that relied on that, set upgrading, because repositories of everyone else stop being served (already stored runs stay). -To let someone in on a closed spindle, an admin does either of these as a -service-authenticated XRPC call to the spindle: +To let someone in on a closed spindle, run one of these on the spindle +host: + +```sh +spindle allowlist add friend.example.com # a handle or a did, also: allowlist remove, list +spindle invite # prints a single use code, good for two days +``` + +Both take effect right away, so the person's next push runs. A running +spindle only backfills what they already had after it restarts, the XRPC +calls below backfill immediately. +Admins can do the same remotely as a service-authenticated XRPC call to +the spindle: - `org.tangled.temp.spindle.allowList.add` with `{"did": "did:plc:friend"}` (and `.remove` / `.list` to manage the list). @@ -249,7 +260,7 @@ spindle ban did:plc:spammer A ban wipes the user's or repository's data on this spindle at the next startup. The `org.tangled.temp.spindle.quota.*` and `.moderation.*` XRPC -methods do the same for the owner remotely. +methods do the same for admins remotely. ## OIDC tokens for workflows diff --git a/spindle/db/invites.go b/spindle/db/invites.go index ebb48f8ec..5532e4d35 100644 --- a/spindle/db/invites.go +++ b/spindle/db/invites.go @@ -1,7 +1,9 @@ package db import ( + "crypto/rand" "database/sql" + "encoding/hex" "errors" "time" @@ -13,23 +15,32 @@ var ( ErrInviteBanned = errors.New("invite redeemer is banned") ) +const InviteTTL = 2 * 24 * time.Hour + // minting sweeps the expired codes, so nothing else has to -func (d *DB) PutInvite(code string, createdBy syntax.DID, now, expires time.Time) error { +func (d *DB) MintInvite(createdBy string, now time.Time) (string, time.Time, error) { + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return "", time.Time{}, err + } + code := hex.EncodeToString(raw) + expires := now.Add(InviteTTL) + tx, err := d.Begin() if err != nil { - return err + return "", time.Time{}, err } defer tx.Rollback() if _, err := tx.Exec(`delete from invite_codes where expires_at <= ?`, now.Unix()); err != nil { - return err + return "", time.Time{}, err } if _, err := tx.Exec( `insert into invite_codes (code, created_by, expires_at) values (?, ?, ?)`, code, createdBy, expires.Unix(), ); err != nil { - return err + return "", time.Time{}, err } - return tx.Commit() + return code, expires, tx.Commit() } // the delete is the spend, so of any number of racing redeems only one sees a row diff --git a/spindle/xrpc/invites.go b/spindle/xrpc/invites.go index 3acef50c5..5c9cdf5e5 100644 --- a/spindle/xrpc/invites.go +++ b/spindle/xrpc/invites.go @@ -1,8 +1,6 @@ package xrpc import ( - "crypto/rand" - "encoding/hex" "encoding/json" "errors" "net/http" @@ -14,11 +12,6 @@ import ( xrpcerr "tangled.org/core/xrpc/errors" ) -const ( - inviteTTL = 2 * 24 * time.Hour - inviteCodeBytes = 16 -) - func (x *Xrpc) now() time.Time { if x.Now != nil { return x.Now() @@ -34,15 +27,8 @@ func (x *Xrpc) CreateInviteCode(w http.ResponseWriter, r *http.Request) { return } - raw := make([]byte, inviteCodeBytes) - if _, err := rand.Read(raw); err != nil { - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) - return - } - code := hex.EncodeToString(raw) - now := x.now() - expires := now.Add(inviteTTL) - if err := x.Db.PutInvite(code, admin, now, expires); err != nil { + code, expires, err := x.Db.MintInvite(admin.String(), x.now()) + if err != nil { writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) return } diff --git a/spindle/xrpc/invites_test.go b/spindle/xrpc/invites_test.go index 5b6ec2088..5fc879b50 100644 --- a/spindle/xrpc/invites_test.go +++ b/spindle/xrpc/invites_test.go @@ -143,7 +143,7 @@ func TestInviteShouldBeRefusedWhenUnknownOrExpired(t *testing.T) { } stale := mint(t, x, owner) - now = now.Add(inviteTTL) + now = now.Add(db.InviteTTL) if w := redeem(x, stranger, stale.Code); w.Code != http.StatusBadRequest || inviteTag(t, w) != "InviteNotFound" { t.Fatalf("expired code = %d: %s", w.Code, w.Body.String()) } @@ -157,7 +157,7 @@ func TestInviteShouldBeRefusedWhenUnknownOrExpired(t *testing.T) { } fresh := mint(t, x, owner) - now = now.Add(inviteTTL - time.Second) + now = now.Add(db.InviteTTL - time.Second) if w := redeem(x, stranger, fresh.Code); w.Code != http.StatusOK { t.Fatalf("code one second before expiry = %d: %s", w.Code, w.Body.String()) } @@ -254,14 +254,15 @@ func TestInviteShouldSurviveARestart(t *testing.T) { } first := open() now := time.Unix(1_800_000_000, 0) - if err := first.PutInvite("abc", "did:plc:admin", now, now.Add(inviteTTL)); err != nil { + code, _, err := first.MintInvite("did:plc:admin", now) + if err != nil { t.Fatal(err) } first.Close() second := open() defer second.Close() - if err := second.RedeemInvite("abc", "did:plc:stranger", now.Add(time.Hour)); err != nil { + if err := second.RedeemInvite(code, "did:plc:stranger", now.Add(time.Hour)); err != nil { t.Fatalf("redeem after reopening the db: %v", err) } } -- 2.54.0