From cf7b0726e6f684ea690ce0434cb17282dbe3f9e0 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Wed, 29 Jul 2026 16:36:04 +0900 Subject: [PATCH] spindle: `spindle admin` command spindle admin allow spindle admin block Signed-off-by: Seongmin Lee --- cmd/spindle/main.go | 39 +++++++++++++++++- spindle/admin.go | 75 +++++++++++++++++++++++++++++++++++ spindle/admin_cmd.go | 50 +++++++++++++++++++++++ spindle/config/config.go | 1 + spindle/config/config_test.go | 1 + spindle/member.go | 30 ++++++++++++++ spindle/server.go | 5 +++ 7 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 spindle/admin.go create mode 100644 spindle/admin_cmd.go create mode 100644 spindle/member.go diff --git a/cmd/spindle/main.go b/cmd/spindle/main.go index c377a7cad..ddb32ccb4 100644 --- a/cmd/spindle/main.go +++ b/cmd/spindle/main.go @@ -15,7 +15,8 @@ func main() { Name: "spindle", Usage: "spindle continuous integration runner", Commands: []*cli.Command{ - Command(), + Run(), + adminCmd, }, DefaultCommand: "run", } @@ -32,7 +33,7 @@ func main() { } } -func Command() *cli.Command { +func Run() *cli.Command { return &cli.Command{ Name: "run", Usage: "run the spindle server", @@ -41,3 +42,37 @@ func Command() *cli.Command { }, } } + +var adminCmd = &cli.Command{ + Name: "admin", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "url", + Value: "http://localhost:6555", + Usage: "spindle server url", + }, + &cli.StringFlag{ + Name: "password", + Usage: "admin password", + Sources: cli.EnvVars("SPINDLE_SERVER_ADMIN_PASSWORD"), + }, + }, + Commands: []*cli.Command{ + { + Name: "allow", + Usage: "allow a did to use this spindle", + ArgsUsage: "", + Action: func(ctx context.Context, c *cli.Command) error { + return spindle.AdminAllowMember(ctx, c.String("url"), c.String("password"), c.Args().First()) + }, + }, + { + Name: "block", + Usage: "block a did from using this spindle", + ArgsUsage: "", + Action: func(ctx context.Context, c *cli.Command) error { + return spindle.AdminBlockMember(ctx, c.String("url"), c.String("password"), c.Args().First()) + }, + }, + }, +} diff --git a/spindle/admin.go b/spindle/admin.go new file mode 100644 index 000000000..175522dbc --- /dev/null +++ b/spindle/admin.go @@ -0,0 +1,75 @@ +package spindle + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "net/http" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/go-chi/chi/v5" +) + +type adminReq struct { + Did string `json:"did"` +} + +func (s *Spindle) adminRouter() http.Handler { + r := chi.NewRouter() + r.Use(s.adminMiddleware) + r.Post("/member/allow", s.handleAdminMemberAllow) + r.Post("/member/block", s.handleAdminMemberBlock) + return r +} + +func (s *Spindle) adminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + password := s.cfg.Server.AdminPassword + u, p, ok := r.BasicAuth() + valid := password != "" && + ok && + u == "admin" && + subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 + if !valid { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Spindle) handleAdminMemberAllow(w http.ResponseWriter, r *http.Request) { + var req adminReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request body", http.StatusBadRequest) + return + } + did, err := syntax.ParseDID(req.Did) + if err != nil { + http.Error(w, fmt.Sprintf("invalid did: %v", err), http.StatusBadRequest) + return + } + if err := s.AllowMember(r.Context(), did); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Spindle) handleAdminMemberBlock(w http.ResponseWriter, r *http.Request) { + var req adminReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request body", http.StatusBadRequest) + return + } + did, err := syntax.ParseDID(req.Did) + if err != nil { + http.Error(w, fmt.Sprintf("invalid did: %v", err), http.StatusBadRequest) + return + } + if err := s.BlockMember(r.Context(), did); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/spindle/admin_cmd.go b/spindle/admin_cmd.go new file mode 100644 index 000000000..e2f475810 --- /dev/null +++ b/spindle/admin_cmd.go @@ -0,0 +1,50 @@ +package spindle + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func AdminAllowMember(ctx context.Context, url, password, did string) error { + if _, err := syntax.ParseDID(did); err != nil { + return fmt.Errorf("invalid did %q: %w", did, err) + } + + return postAdmin(ctx, password, url+"/admin/member/allow", adminReq{Did: did}) +} + +func AdminBlockMember(ctx context.Context, url, password, did string) error { + if _, err := syntax.ParseDID(did); err != nil { + return fmt.Errorf("invalid did %q: %w", did, err) + } + + return postAdmin(ctx, password, url+"/admin/member/block", adminReq{Did: did}) +} + +func postAdmin(ctx context.Context, password, url string, body any) error { + encoded, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth("admin", password) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode/100 != 2 { + msg, _ := io.ReadAll(resp.Body) + return fmt.Errorf("spindle returned %s: %s", resp.Status, bytes.TrimSpace(msg)) + } + return nil +} diff --git a/spindle/config/config.go b/spindle/config/config.go index 47255a1ef..e89bceea6 100644 --- a/spindle/config/config.go +++ b/spindle/config/config.go @@ -10,6 +10,7 @@ import ( ) type Server struct { + AdminPassword string `env:"ADMIN_PASSWORD"` ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` DBPath string `env:"DB_PATH, default=spindle.db"` RepoDir string `env:"REPO_DIR, default=repos"` diff --git a/spindle/config/config_test.go b/spindle/config/config_test.go index 78efd66cf..354aa2d9d 100644 --- a/spindle/config/config_test.go +++ b/spindle/config/config_test.go @@ -7,6 +7,7 @@ import ( func TestLoadAllowsUnconfiguredMicroVMEngine(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") + t.Setenv("SPINDLE_SERVER_ADMIN_PASSWORD", "strong_password") t.Setenv("SPINDLE_MICROVM_PIPELINES_IMAGE_DIR", "") cfg, err := Load(context.Background()) diff --git a/spindle/member.go b/spindle/member.go new file mode 100644 index 000000000..68cea05e2 --- /dev/null +++ b/spindle/member.go @@ -0,0 +1,30 @@ +package spindle + +import ( + "context" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func (s *Spindle) AllowMember(ctx context.Context, did syntax.DID) error { + if err := s.db.UpsertMember(ctx, did, false); err != nil { + return err + } + s.jc.AddDid(did.String()) + // tap is best-effort: jetstream carries sh.tangled.repo too, and onConnect re-declares + if err := s.tap.tap.AddRepos(ctx, []syntax.DID{did}); err != nil { + s.l.Warn("tap: failed to add member did", "did", did, "err", err) + } + return nil +} + +func (s *Spindle) BlockMember(ctx context.Context, did syntax.DID) error { + if err := s.db.UpsertMember(ctx, did, true); err != nil { + return err + } + s.jc.RemoveDid(did.String()) + if err := s.tap.tap.RemoveRepos(ctx, []syntax.DID{did}); err != nil { + s.l.Warn("tap: failed to remove member did", "did", did, "err", err) + } + return nil +} diff --git a/spindle/server.go b/spindle/server.go index 9c28208c3..82e244415 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -358,6 +358,11 @@ func (s *Spindle) Router() http.Handler { mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) mux.Mount("/xrpc", s.XrpcRouter()) + if s.cfg.Server.AdminPassword != "" { + mux.Mount("/admin", s.adminRouter()) + } else { + s.l.Warn("admin api disabled: SPINDLE_SERVER_ADMIN_PASSWORD is unset") + } return mux } -- 2.51.2