This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

spindle: `spindle admin` command

spindle admin allow <user>
spindle admin block <user>

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Jul 29, 2026, 5:38 PM +0900) commit cf7b0726 parent d73ce739 change-id srtnmrnv
+199 -2
+37 -2
cmd/spindle/main.go
··· 15 15 Name: "spindle", 16 16 Usage: "spindle continuous integration runner", 17 17 Commands: []*cli.Command{ 18 - Command(), 18 + Run(), 19 + adminCmd, 19 20 }, 20 21 DefaultCommand: "run", 21 22 } ··· 32 33 } 33 34 } 34 35 35 - func Command() *cli.Command { 36 + func Run() *cli.Command { 36 37 return &cli.Command{ 37 38 Name: "run", 38 39 Usage: "run the spindle server", ··· 41 42 }, 42 43 } 43 44 } 45 + 46 + var adminCmd = &cli.Command{ 47 + Name: "admin", 48 + Flags: []cli.Flag{ 49 + &cli.StringFlag{ 50 + Name: "url", 51 + Value: "http://localhost:6555", 52 + Usage: "spindle server url", 53 + }, 54 + &cli.StringFlag{ 55 + Name: "password", 56 + Usage: "admin password", 57 + Sources: cli.EnvVars("SPINDLE_SERVER_ADMIN_PASSWORD"), 58 + }, 59 + }, 60 + Commands: []*cli.Command{ 61 + { 62 + Name: "allow", 63 + Usage: "allow a did to use this spindle", 64 + ArgsUsage: "<did>", 65 + Action: func(ctx context.Context, c *cli.Command) error { 66 + return spindle.AdminAllowMember(ctx, c.String("url"), c.String("password"), c.Args().First()) 67 + }, 68 + }, 69 + { 70 + Name: "block", 71 + Usage: "block a did from using this spindle", 72 + ArgsUsage: "<did>", 73 + Action: func(ctx context.Context, c *cli.Command) error { 74 + return spindle.AdminBlockMember(ctx, c.String("url"), c.String("password"), c.Args().First()) 75 + }, 76 + }, 77 + }, 78 + }
+75
spindle/admin.go
··· 1 + package spindle 2 + 3 + import ( 4 + "crypto/subtle" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/go-chi/chi/v5" 11 + ) 12 + 13 + type adminReq struct { 14 + Did string `json:"did"` 15 + } 16 + 17 + func (s *Spindle) adminRouter() http.Handler { 18 + r := chi.NewRouter() 19 + r.Use(s.adminMiddleware) 20 + r.Post("/member/allow", s.handleAdminMemberAllow) 21 + r.Post("/member/block", s.handleAdminMemberBlock) 22 + return r 23 + } 24 + 25 + func (s *Spindle) adminMiddleware(next http.Handler) http.Handler { 26 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 27 + password := s.cfg.Server.AdminPassword 28 + u, p, ok := r.BasicAuth() 29 + valid := password != "" && 30 + ok && 31 + u == "admin" && 32 + subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 33 + if !valid { 34 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 35 + return 36 + } 37 + next.ServeHTTP(w, r) 38 + }) 39 + } 40 + 41 + func (s *Spindle) handleAdminMemberAllow(w http.ResponseWriter, r *http.Request) { 42 + var req adminReq 43 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 44 + http.Error(w, "bad request body", http.StatusBadRequest) 45 + return 46 + } 47 + did, err := syntax.ParseDID(req.Did) 48 + if err != nil { 49 + http.Error(w, fmt.Sprintf("invalid did: %v", err), http.StatusBadRequest) 50 + return 51 + } 52 + if err := s.AllowMember(r.Context(), did); err != nil { 53 + http.Error(w, err.Error(), http.StatusInternalServerError) 54 + return 55 + } 56 + w.WriteHeader(http.StatusNoContent) 57 + } 58 + 59 + func (s *Spindle) handleAdminMemberBlock(w http.ResponseWriter, r *http.Request) { 60 + var req adminReq 61 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 62 + http.Error(w, "bad request body", http.StatusBadRequest) 63 + return 64 + } 65 + did, err := syntax.ParseDID(req.Did) 66 + if err != nil { 67 + http.Error(w, fmt.Sprintf("invalid did: %v", err), http.StatusBadRequest) 68 + return 69 + } 70 + if err := s.BlockMember(r.Context(), did); err != nil { 71 + http.Error(w, err.Error(), http.StatusInternalServerError) 72 + return 73 + } 74 + w.WriteHeader(http.StatusNoContent) 75 + }
+50
spindle/admin_cmd.go
··· 1 + package spindle 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + ) 13 + 14 + func AdminAllowMember(ctx context.Context, url, password, did string) error { 15 + if _, err := syntax.ParseDID(did); err != nil { 16 + return fmt.Errorf("invalid did %q: %w", did, err) 17 + } 18 + 19 + return postAdmin(ctx, password, url+"/admin/member/allow", adminReq{Did: did}) 20 + } 21 + 22 + func AdminBlockMember(ctx context.Context, url, password, did string) error { 23 + if _, err := syntax.ParseDID(did); err != nil { 24 + return fmt.Errorf("invalid did %q: %w", did, err) 25 + } 26 + 27 + return postAdmin(ctx, password, url+"/admin/member/block", adminReq{Did: did}) 28 + } 29 + 30 + func postAdmin(ctx context.Context, password, url string, body any) error { 31 + encoded, _ := json.Marshal(body) 32 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded)) 33 + if err != nil { 34 + return err 35 + } 36 + req.Header.Set("Content-Type", "application/json") 37 + req.SetBasicAuth("admin", password) 38 + 39 + resp, err := http.DefaultClient.Do(req) 40 + if err != nil { 41 + return err 42 + } 43 + defer resp.Body.Close() 44 + 45 + if resp.StatusCode/100 != 2 { 46 + msg, _ := io.ReadAll(resp.Body) 47 + return fmt.Errorf("spindle returned %s: %s", resp.Status, bytes.TrimSpace(msg)) 48 + } 49 + return nil 50 + }
+1
spindle/config/config.go
··· 10 10 ) 11 11 12 12 type Server struct { 13 + AdminPassword string `env:"ADMIN_PASSWORD"` 13 14 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 14 15 DBPath string `env:"DB_PATH, default=spindle.db"` 15 16 RepoDir string `env:"REPO_DIR, default=repos"`
+1
spindle/config/config_test.go
··· 7 7 8 8 func TestLoadAllowsUnconfiguredMicroVMEngine(t *testing.T) { 9 9 t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") 10 + t.Setenv("SPINDLE_SERVER_ADMIN_PASSWORD", "strong_password") 10 11 t.Setenv("SPINDLE_MICROVM_PIPELINES_IMAGE_DIR", "") 11 12 12 13 cfg, err := Load(context.Background())
+30
spindle/member.go
··· 1 + package spindle 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + func (s *Spindle) AllowMember(ctx context.Context, did syntax.DID) error { 10 + if err := s.db.UpsertMember(ctx, did, false); err != nil { 11 + return err 12 + } 13 + s.jc.AddDid(did.String()) 14 + // tap is best-effort: jetstream carries sh.tangled.repo too, and onConnect re-declares 15 + if err := s.tap.tap.AddRepos(ctx, []syntax.DID{did}); err != nil { 16 + s.l.Warn("tap: failed to add member did", "did", did, "err", err) 17 + } 18 + return nil 19 + } 20 + 21 + func (s *Spindle) BlockMember(ctx context.Context, did syntax.DID) error { 22 + if err := s.db.UpsertMember(ctx, did, true); err != nil { 23 + return err 24 + } 25 + s.jc.RemoveDid(did.String()) 26 + if err := s.tap.tap.RemoveRepos(ctx, []syntax.DID{did}); err != nil { 27 + s.l.Warn("tap: failed to remove member did", "did", did, "err", err) 28 + } 29 + return nil 30 + }
+5
spindle/server.go
··· 358 358 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 359 359 360 360 mux.Mount("/xrpc", s.XrpcRouter()) 361 + if s.cfg.Server.AdminPassword != "" { 362 + mux.Mount("/admin", s.adminRouter()) 363 + } else { 364 + s.l.Warn("admin api disabled: SPINDLE_SERVER_ADMIN_PASSWORD is unset") 365 + } 361 366 return mux 362 367 } 363 368