diff --git a/cmd/spindle/main.go b/cmd/spindle/main.go index c377a7ca..eaebb852 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 user to use spindle", + ArgsUsage: "", + Action: func(ctx context.Context, c *cli.Command) error { + return spindle.AdminAllowUser(ctx, c.String("url"), c.String("password"), c.Args().First()) + }, + }, + { + Name: "block", + Usage: "block user from using spindle", + ArgsUsage: "", + Action: func(ctx context.Context, c *cli.Command) error { + return spindle.AdminBlockUser(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 00000000..c21e600d --- /dev/null +++ b/spindle/admin.go @@ -0,0 +1,70 @@ +package spindle + +import ( + "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("/user/allow", s.handleAdminUserAllow) + r.Post("/user/block", s.handleAdminUserBlock) + return r +} + +func (s *Spindle) adminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + if !ok || u != "admin" || p != s.cfg.Server.AdminPassword { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Spindle) handleAdminUserAllow(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.AllowUser(r.Context(), did); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Spindle) handleAdminUserBlock(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.BlockUser(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 00000000..8e3e7c87 --- /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 AdminAllowUser(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/user/allow", adminReq{Did: did}) +} + +func AdminBlockUser(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/user/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 47255a1e..85888a57 100644 --- a/spindle/config/config.go +++ b/spindle/config/config.go @@ -10,6 +10,7 @@ import ( ) type Server struct { + AdminPassword string `env:"ADMIN_PASSWORD, required"` 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/server.go b/spindle/server.go index 39202f26..2c6dcfe0 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -324,6 +324,7 @@ func (s *Spindle) Router() http.Handler { mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) mux.Mount("/xrpc", s.XrpcRouter()) + mux.Mount("/admin", s.adminRouter()) return mux } diff --git a/spindle/user.go b/spindle/user.go index d14a6700..a7857700 100644 --- a/spindle/user.go +++ b/spindle/user.go @@ -7,9 +7,23 @@ import ( ) func (s *Spindle) AllowUser(ctx context.Context, did syntax.DID) error { - return s.db.UpsertUser(ctx, did, false) + if err := s.db.UpsertUser(ctx, did, false); err != nil { + return err + } + s.jc.AddDid(did.String()) + if err := s.tap.tap.AddRepos(ctx, []syntax.DID{did}); err != nil { + return err + } + return nil } func (s *Spindle) BlockUser(ctx context.Context, did syntax.DID) error { - return s.db.UpsertUser(ctx, did, true) + if err := s.db.UpsertUser(ctx, did, true); err != nil { + return err + } + s.jc.RemoveDid(did.String()) + if err := s.tap.tap.RemoveRepos(ctx, []syntax.DID{did}); err != nil { + return err + } + return nil }