Commit 8352b440f4
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/main.go +10 −1
| @@ -81,7 +81,16 @@ func newRoot() *cobra.Command { | |||
| 81 | pass("show", "show an account: <username>", passOpts{server: []string{"admin", "user", "show"}}), | 81 | pass("show", "show an account: <username>", passOpts{server: []string{"admin", "user", "show"}}), |
| 82 | pass("promote", "make an account an instance admin: <username>", passOpts{server: []string{"admin", "user", "promote"}}), | 82 | pass("promote", "make an account an instance admin: <username>", passOpts{server: []string{"admin", "user", "promote"}}), |
| 83 | pass("demote", "remove instance admin (never the last one): <username>", passOpts{server: []string{"admin", "user", "demote"}}), | 83 | pass("demote", "remove instance admin (never the last one): <username>", passOpts{server: []string{"admin", "user", "demote"}}), |
| 84 | pass("create", "create an account: <username> [--admin] [--email a [--verified]] [--key -] < key.pub", passOpts{server: []string{"admin", "user", "create"}, stdinOK: true}), | ||
| 85 | pass("disable", "suspend an account: <username>", passOpts{server: []string{"admin", "user", "disable"}}), | ||
| 86 | pass("enable", "restore a suspended account: <username>", passOpts{server: []string{"admin", "user", "enable"}}), | ||
| 87 | pass("delete", "delete an account that anchors nothing: <username> --yes", passOpts{server: []string{"admin", "user", "delete"}}), | ||
| 84 | ), | 88 | ), |
| 89 | group("email", "addresses on any account", | ||
| 90 | pass("verify", "mark an address verified by admin assertion: <username> <address>", passOpts{server: []string{"admin", "email", "verify"}}), | ||
| 91 | ), | ||
| 92 | pass("invite", "issue a registration invite and mail its code: --email <address>", passOpts{server: []string{"admin", "invite"}}), | ||
| 93 | pass("stats", "instance statistics: counts and per-repository disk usage", passOpts{server: []string{"admin", "stats"}}), | ||
| 85 | group("repo", "any repository, for moderation (audited)", | 94 | group("repo", "any repository, for moderation (audited)", |
| 86 | pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}), | 95 | pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}), |
| 87 | pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}), | 96 | pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}), |
| @@ -209,7 +218,7 @@ func isEmptyReader(r io.Reader) bool { | |||
| 209 | // usesStdin reports whether the arguments request stdin content. | 218 | // usesStdin reports whether the arguments request stdin content. |
| 210 | func usesStdin(args []string) bool { | 219 | func usesStdin(args []string) bool { |
| 211 | for i, a := range args { | 220 | for i, a := range args { |
| 212 | if a == "--file" && i+1 < len(args) && args[i+1] == "-" { | 221 | if (a == "--file" || a == "--key") && i+1 < len(args) && args[i+1] == "-" { |
| 213 | return true | 222 | return true |
| 214 | } | 223 | } |
| 215 | if a == "--token-stdin" { | 224 | if a == "--token-stdin" { |
cmd/gitbayd/adminusers.go −126
| @@ -8,101 +8,8 @@ import ( | |||
| 8 | "gitbay.org/gitbay/internal/config" | 8 | "gitbay.org/gitbay/internal/config" |
| 9 | "gitbay.org/gitbay/internal/control" | 9 | "gitbay.org/gitbay/internal/control" |
| 10 | "gitbay.org/gitbay/internal/gitutil" | 10 | "gitbay.org/gitbay/internal/gitutil" |
| 11 | "gitbay.org/gitbay/internal/store" | ||
| 12 | ) | 11 | ) |
| 13 | 12 | ||
| 14 | func withUser(use, short string, run func(st *store.Store, u store.User) error) *cobra.Command { | ||
| 15 | return &cobra.Command{ | ||
| 16 | Use: use + " <username>", | ||
| 17 | Short: short, | ||
| 18 | Args: cobra.ExactArgs(1), | ||
| 19 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 20 | cfg, err := config.Load(configPath) | ||
| 21 | if err != nil { | ||
| 22 | return err | ||
| 23 | } | ||
| 24 | st, err := openStore(cfg) | ||
| 25 | if err != nil { | ||
| 26 | return err | ||
| 27 | } | ||
| 28 | defer st.Close() | ||
| 29 | u, err := st.UserByUsername(args[0]) | ||
| 30 | if err != nil { | ||
| 31 | return fmt.Errorf("no user %q", args[0]) | ||
| 32 | } | ||
| 33 | return run(st, u) | ||
| 34 | }, | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | func adminUserDisableCmd() *cobra.Command { | ||
| 39 | return withUser("disable", "suspend an account: keys and sessions refused until re-enabled", | ||
| 40 | func(st *store.Store, u store.User) error { | ||
| 41 | if err := st.SetUserDisabled(u.ID, true); err != nil { | ||
| 42 | return err | ||
| 43 | } | ||
| 44 | st.Audit(0, "admin user.disabled", map[string]any{"user": u.Username}) | ||
| 45 | fmt.Printf("disabled %s: SSH, web sessions, and API tokens are refused; nothing was deleted\n", u.Username) | ||
| 46 | return nil | ||
| 47 | }) | ||
| 48 | } | ||
| 49 | |||
| 50 | func adminUserDeleteCmd() *cobra.Command { | ||
| 51 | var yes bool | ||
| 52 | cmd := withUser("delete", "delete an account that anchors nothing (keys, emails, and sessions go with it)", | ||
| 53 | func(st *store.Store, u store.User) error { | ||
| 54 | if !yes { | ||
| 55 | return fmt.Errorf("deletion is permanent; pass --yes") | ||
| 56 | } | ||
| 57 | if err := st.DeleteUser(u.ID); err != nil { | ||
| 58 | return err | ||
| 59 | } | ||
| 60 | st.Audit(0, "admin user.deleted", map[string]any{"user": u.Username}) | ||
| 61 | fmt.Printf("deleted %s\n", u.Username) | ||
| 62 | return nil | ||
| 63 | }) | ||
| 64 | cmd.Flags().BoolVar(&yes, "yes", false, "confirm permanent deletion") | ||
| 65 | return cmd | ||
| 66 | } | ||
| 67 | |||
| 68 | func adminUserEnableCmd() *cobra.Command { | ||
| 69 | return withUser("enable", "restore a suspended account", | ||
| 70 | func(st *store.Store, u store.User) error { | ||
| 71 | if err := st.SetUserDisabled(u.ID, false); err != nil { | ||
| 72 | return err | ||
| 73 | } | ||
| 74 | st.Audit(0, "admin user.enabled", map[string]any{"user": u.Username}) | ||
| 75 | fmt.Printf("enabled %s\n", u.Username) | ||
| 76 | return nil | ||
| 77 | }) | ||
| 78 | } | ||
| 79 | |||
| 80 | // adminUserPromoteCmd is the recovery path when no admin key is reachable: | ||
| 81 | // it needs the host, not an admin session. | ||
| 82 | func adminUserPromoteCmd() *cobra.Command { | ||
| 83 | return withUser("promote", "make an account an instance admin", | ||
| 84 | func(st *store.Store, u store.User) error { | ||
| 85 | if err := st.SetUserAdmin(u.ID, true); err != nil { | ||
| 86 | return err | ||
| 87 | } | ||
| 88 | st.Audit(0, "admin user.promoted", map[string]any{"user": u.Username}) | ||
| 89 | fmt.Printf("promoted %s\n", u.Username) | ||
| 90 | return nil | ||
| 91 | }) | ||
| 92 | } | ||
| 93 | |||
| 94 | func adminUserDemoteCmd() *cobra.Command { | ||
| 95 | return withUser("demote", "remove instance admin from an account (never the last one)", | ||
| 96 | func(st *store.Store, u store.User) error { | ||
| 97 | if err := st.SetUserAdmin(u.ID, false); err != nil { | ||
| 98 | return err | ||
| 99 | } | ||
| 100 | st.Audit(0, "admin user.demoted", map[string]any{"user": u.Username}) | ||
| 101 | fmt.Printf("demoted %s\n", u.Username) | ||
| 102 | return nil | ||
| 103 | }) | ||
| 104 | } | ||
| 105 | |||
| 106 | // adminMigrateCommitRefsCmd is a one-shot backfill: legacy commit-reference | 13 | // adminMigrateCommitRefsCmd is a one-shot backfill: legacy commit-reference |
| 107 | // comments (author-attributed, bare sha) become system messages with a | 14 | // comments (author-attributed, bare sha) become system messages with a |
| 108 | // linked sha. Idempotent. | 15 | // linked sha. Idempotent. |
| @@ -178,36 +85,3 @@ func adminBackfillActivityCmd() *cobra.Command { | |||
| 178 | cmd.Flags().IntVar(&perRepo, "per-repo", 20000, "max commits walked per repository") | 85 | cmd.Flags().IntVar(&perRepo, "per-repo", 20000, "max commits walked per repository") |
| 179 | return cmd | 86 | return cmd |
| 180 | } | 87 | } |
| 181 | |||
| 182 | func adminAuditCmd() *cobra.Command { | ||
| 183 | var limit int | ||
| 184 | cmd := &cobra.Command{ | ||
| 185 | Use: "audit", | ||
| 186 | Short: "print the security audit log, newest first", | ||
| 187 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 188 | cfg, err := config.Load(configPath) | ||
| 189 | if err != nil { | ||
| 190 | return err | ||
| 191 | } | ||
| 192 | st, err := openStore(cfg) | ||
| 193 | if err != nil { | ||
| 194 | return err | ||
| 195 | } | ||
| 196 | defer st.Close() | ||
| 197 | entries, err := st.AuditEntries(limit) | ||
| 198 | if err != nil { | ||
| 199 | return err | ||
| 200 | } | ||
| 201 | for _, e := range entries { | ||
| 202 | actor := e.Actor | ||
| 203 | if actor == "" { | ||
| 204 | actor = "-" | ||
| 205 | } | ||
| 206 | fmt.Printf("%s\t%s\t%s\t%s\n", e.CreatedAt, actor, e.Action, e.Data) | ||
| 207 | } | ||
| 208 | return nil | ||
| 209 | }, | ||
| 210 | } | ||
| 211 | cmd.Flags().IntVar(&limit, "limit", 100, "entries to print") | ||
| 212 | return cmd | ||
| 213 | } | ||
cmd/gitbayd/main.go +106 −135
| @@ -5,6 +5,7 @@ package main | |||
| 5 | import ( | 5 | import ( |
| 6 | "context" | 6 | "context" |
| 7 | "fmt" | 7 | "fmt" |
| 8 | "io" | ||
| 8 | "log/slog" | 9 | "log/slog" |
| 9 | "net" | 10 | "net" |
| 10 | "net/http" | 11 | "net/http" |
| @@ -16,7 +17,6 @@ import ( | |||
| 16 | 17 | ||
| 17 | "github.com/spf13/cobra" | 18 | "github.com/spf13/cobra" |
| 18 | "golang.org/x/crypto/acme/autocert" | 19 | "golang.org/x/crypto/acme/autocert" |
| 19 | "golang.org/x/crypto/ssh" | ||
| 20 | 20 | ||
| 21 | "gitbay.org/gitbay/internal/buildinfo" | 21 | "gitbay.org/gitbay/internal/buildinfo" |
| 22 | "gitbay.org/gitbay/internal/ci" | 22 | "gitbay.org/gitbay/internal/ci" |
| @@ -26,10 +26,8 @@ import ( | |||
| 26 | "gitbay.org/gitbay/internal/gitd" | 26 | "gitbay.org/gitbay/internal/gitd" |
| 27 | "gitbay.org/gitbay/internal/hookd" | 27 | "gitbay.org/gitbay/internal/hookd" |
| 28 | "gitbay.org/gitbay/internal/httpd" | 28 | "gitbay.org/gitbay/internal/httpd" |
| 29 | "gitbay.org/gitbay/internal/mail" | ||
| 30 | "gitbay.org/gitbay/internal/mirror" | 29 | "gitbay.org/gitbay/internal/mirror" |
| 31 | "gitbay.org/gitbay/internal/notify" | 30 | "gitbay.org/gitbay/internal/notify" |
| 32 | "gitbay.org/gitbay/internal/policy" | ||
| 33 | "gitbay.org/gitbay/internal/sshd" | 31 | "gitbay.org/gitbay/internal/sshd" |
| 34 | "gitbay.org/gitbay/internal/store" | 32 | "gitbay.org/gitbay/internal/store" |
| 35 | "gitbay.org/gitbay/internal/webhook" | 33 | "gitbay.org/gitbay/internal/webhook" |
| @@ -309,164 +307,137 @@ func adminCmd() *cobra.Command { | |||
| 309 | Short: "host-local administration", | 307 | Short: "host-local administration", |
| 310 | } | 308 | } |
| 311 | userCmd := &cobra.Command{Use: "user", Short: "manage users"} | 309 | userCmd := &cobra.Command{Use: "user", Short: "manage users"} |
| 312 | userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd(), adminUserDeleteCmd(), | 310 | userCmd.AddCommand( |
| 313 | adminUserPromoteCmd(), adminUserDemoteCmd()) | 311 | hostUserCreateCmd(), |
| 312 | hostCmd("list [--state active|pending|disabled|admin] [--limit n] [--cursor c]", "list accounts", "admin", "user", "list"), | ||
| 313 | hostCmd("show <username>", "show an account: keys, emails, orgs, tokens, sessions", "admin", "user", "show"), | ||
| 314 | hostCmd("disable <username>", "suspend an account: keys and sessions refused until re-enabled", "admin", "user", "disable"), | ||
| 315 | hostCmd("enable <username>", "restore a suspended account", "admin", "user", "enable"), | ||
| 316 | hostCmd("delete <username> --yes", "delete an account that anchors nothing (keys, emails, and sessions go with it)", "admin", "user", "delete"), | ||
| 317 | hostCmd("promote <username>", "make an account an instance admin", "admin", "user", "promote"), | ||
| 318 | hostCmd("demote <username>", "remove instance admin from an account (never the last one)", "admin", "user", "demote"), | ||
| 319 | ) | ||
| 314 | emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} | 320 | emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} |
| 315 | emailCmd.AddCommand(adminEmailVerifyCmd()) | 321 | emailCmd.AddCommand(hostCmd("verify <username> <address>", "mark an email verified by admin assertion", "admin", "email", "verify")) |
| 322 | repoCmd := &cobra.Command{Use: "repo", Short: "any repository, for moderation (audited)"} | ||
| 323 | repoCmd.AddCommand( | ||
| 324 | hostCmd("list [--owner o] [--visibility public|private] [--limit n] [--cursor c]", "every repository with size and last push", "admin", "repo", "list"), | ||
| 325 | hostCmd("archive <owner/name>", "archive a repository", "admin", "repo", "archive"), | ||
| 326 | hostCmd("unarchive <owner/name>", "unarchive a repository", "admin", "repo", "unarchive"), | ||
| 327 | hostCmd("visibility <owner/name> public|private", "set a repository's visibility", "admin", "repo", "visibility"), | ||
| 328 | hostCmd("delete <owner/name> --yes", "delete a repository", "admin", "repo", "delete"), | ||
| 329 | ) | ||
| 316 | admin.AddCommand( | 330 | admin.AddCommand( |
| 317 | userCmd, | 331 | userCmd, |
| 318 | emailCmd, | 332 | emailCmd, |
| 319 | adminInviteCmd(), | 333 | repoCmd, |
| 334 | hostCmd("invite --email <address>", "issue a registration invite and email its code", "admin", "invite"), | ||
| 335 | hostCmd("stats [--json]", "instance statistics: counts and per-repository disk usage", "admin", "stats"), | ||
| 336 | hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit"), | ||
| 320 | backupCmd(), | 337 | backupCmd(), |
| 321 | gcCmd(), | 338 | gcCmd(), |
| 322 | statsCmd(), | ||
| 323 | adminAuditCmd(), | ||
| 324 | adminMigrateCommitRefsCmd(), | 339 | adminMigrateCommitRefsCmd(), |
| 325 | adminBackfillActivityCmd(), | 340 | adminBackfillActivityCmd(), |
| 326 | ) | 341 | ) |
| 327 | return admin | 342 | return admin |
| 328 | } | 343 | } |
| 329 | 344 | ||
| 330 | func adminInviteCmd() *cobra.Command { | 345 | // hostCmd runs a registry command as the host itself: an admin context |
| 331 | var email string | 346 | // with no account behind it, so audit rows carry no actor and the source |
| 332 | cmd := &cobra.Command{ | 347 | // "host". Arguments pass through untouched; the registry owns the flags, |
| 333 | Use: "invite", | 348 | // which is what keeps this surface and an admin's SSH session from |
| 334 | Short: "issue a registration invite and email its code", | 349 | // drifting. |
| 350 | func hostCmd(use, short string, path ...string) *cobra.Command { | ||
| 351 | return &cobra.Command{ | ||
| 352 | Use: use, | ||
| 353 | Short: short, | ||
| 354 | DisableFlagParsing: true, | ||
| 335 | RunE: func(cmd *cobra.Command, args []string) error { | 355 | RunE: func(cmd *cobra.Command, args []string) error { |
| 336 | if email == "" { | 356 | for _, a := range args { |
| 337 | return fmt.Errorf("--email is required") | 357 | if a == "--help" || a == "-h" { |
| 338 | } | 358 | return cmd.Help() |
| 339 | cfg, err := config.Load(configPath) | ||
| 340 | if err != nil { | ||
| 341 | return err | ||
| 342 | } | ||
| 343 | st, err := openStore(cfg) | ||
| 344 | if err != nil { | ||
| 345 | return err | ||
| 346 | } | ||
| 347 | defer st.Close() | ||
| 348 | |||
| 349 | if used, err := st.EmailInUse(email); err != nil { | ||
| 350 | return err | ||
| 351 | } else if used { | ||
| 352 | return fmt.Errorf("%s already belongs to an account; invites are for new users", email) | ||
| 353 | } | ||
| 354 | code, hash, err := store.NewToken() | ||
| 355 | if err != nil { | ||
| 356 | return err | ||
| 357 | } | ||
| 358 | if err := st.CreateInvite(hash, email); err != nil { | ||
| 359 | return err | ||
| 360 | } | ||
| 361 | host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://"), "/") | ||
| 362 | body := fmt.Sprintf( | ||
| 363 | "You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+ | ||
| 364 | " ssh git@%s register --username <name> --invite %s\n\n"+ | ||
| 365 | "The invite is single-use and tied to this address.\n", host, host, code) | ||
| 366 | if cfg.Mail.SMTPHost != "" { | ||
| 367 | if err := mail.Send(cfg, email, "your invite to "+host, body); err != nil { | ||
| 368 | return fmt.Errorf("invite stored but mail failed: %w (code: %s)", err, code) | ||
| 369 | } | 359 | } |
| 370 | st.Audit(0, "admin invite.issued", map[string]any{"email": email}) | ||
| 371 | fmt.Printf("invite emailed to %s\n", email) | ||
| 372 | } else { | ||
| 373 | fmt.Printf("invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code) | ||
| 374 | } | 360 | } |
| 375 | return nil | 361 | return runAsHost(path, args, os.Stdin) |
| 376 | }, | 362 | }, |
| 377 | } | 363 | } |
| 378 | cmd.Flags().StringVar(&email, "email", "", "address to invite (the account's verified email)") | ||
| 379 | return cmd | ||
| 380 | } | 364 | } |
| 381 | 365 | ||
| 382 | func adminUserCreateCmd() *cobra.Command { | 366 | // hostArgs pulls the root's --config out of args: with flag parsing off, |
| 383 | var keyPath, email string | 367 | // cobra hands the persistent flag through untouched. |
| 384 | var verified, isAdmin bool | 368 | func hostArgs(args []string) []string { |
| 385 | cmd := &cobra.Command{ | 369 | var rest []string |
| 386 | Use: "create <username>", | 370 | for i := 0; i < len(args); i++ { |
| 387 | Short: "create a user (host-local bootstrap; the only path in closed mode)", | 371 | switch { |
| 388 | Args: cobra.ExactArgs(1), | 372 | case args[i] == "--config" && i+1 < len(args): |
| 389 | RunE: func(cmd *cobra.Command, args []string) error { | 373 | configPath = args[i+1] |
| 390 | username := args[0] | 374 | i++ |
| 391 | if err := policy.ValidateOwnerName(username); err != nil { | 375 | case strings.HasPrefix(args[i], "--config="): |
| 392 | return err | 376 | configPath = strings.TrimPrefix(args[i], "--config=") |
| 393 | } | 377 | default: |
| 394 | cfg, err := config.Load(configPath) | 378 | rest = append(rest, args[i]) |
| 395 | if err != nil { | 379 | } |
| 396 | return err | 380 | } |
| 397 | } | 381 | return rest |
| 398 | st, err := openStore(cfg) | 382 | } |
| 399 | if err != nil { | ||
| 400 | return err | ||
| 401 | } | ||
| 402 | defer st.Close() | ||
| 403 | 383 | ||
| 404 | uid, err := st.CreateUser(username, isAdmin) | 384 | func runAsHost(path, args []string, stdin io.Reader) error { |
| 405 | if err != nil { | 385 | args = hostArgs(args) |
| 406 | return err | 386 | cfg, err := config.Load(configPath) |
| 407 | } | 387 | if err != nil { |
| 408 | if email != "" { | 388 | return err |
| 409 | verifiedBy := "" | ||
| 410 | if verified { | ||
| 411 | verifiedBy = "admin" | ||
| 412 | } | ||
| 413 | if err := st.AddEmail(uid, email, verifiedBy, true); err != nil { | ||
| 414 | return err | ||
| 415 | } | ||
| 416 | } | ||
| 417 | if keyPath != "" { | ||
| 418 | raw, err := os.ReadFile(keyPath) | ||
| 419 | if err != nil { | ||
| 420 | return err | ||
| 421 | } | ||
| 422 | pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) | ||
| 423 | if err != nil { | ||
| 424 | return fmt.Errorf("%s: not a public key in authorized_keys format: %w", keyPath, err) | ||
| 425 | } | ||
| 426 | fp := ssh.FingerprintSHA256(pub) | ||
| 427 | if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil { | ||
| 428 | return err | ||
| 429 | } | ||
| 430 | fmt.Println("key", fp) | ||
| 431 | } | ||
| 432 | st.Audit(0, "admin user.created", map[string]any{"user": username}) | ||
| 433 | fmt.Println("created user", username) | ||
| 434 | return nil | ||
| 435 | }, | ||
| 436 | } | 389 | } |
| 437 | cmd.Flags().StringVar(&keyPath, "key", "", "path to an SSH public key to register") | 390 | st, err := openStore(cfg) |
| 438 | cmd.Flags().StringVar(&email, "email", "", "primary email address") | 391 | if err != nil { |
| 439 | cmd.Flags().BoolVar(&verified, "verified", false, "mark the email verified (admin assertion)") | 392 | return err |
| 440 | cmd.Flags().BoolVar(&isAdmin, "admin", false, "grant instance admin") | 393 | } |
| 441 | return cmd | 394 | c := &control.Ctx{ |
| 395 | User: store.User{Username: "host", IsAdmin: true}, | ||
| 396 | Scope: "full", | ||
| 397 | Store: st, | ||
| 398 | Cfg: cfg, | ||
| 399 | Stdin: stdin, | ||
| 400 | Stdout: os.Stdout, | ||
| 401 | Stderr: os.Stderr, | ||
| 402 | Source: "host", | ||
| 403 | } | ||
| 404 | code := control.Dispatch(c, append(append([]string{}, path...), args...)) | ||
| 405 | st.Close() | ||
| 406 | if code != 0 { | ||
| 407 | os.Exit(code) | ||
| 408 | } | ||
| 409 | return nil | ||
| 442 | } | 410 | } |
| 443 | 411 | ||
| 444 | func adminEmailVerifyCmd() *cobra.Command { | 412 | // hostUserCreateCmd keeps --key <path>, which the registry command cannot |
| 413 | // take (no file paths over SSH): the file becomes the command's stdin. | ||
| 414 | func hostUserCreateCmd() *cobra.Command { | ||
| 445 | return &cobra.Command{ | 415 | return &cobra.Command{ |
| 446 | Use: "verify <username> <address>", | 416 | Use: "create <username> [--admin] [--email <address> [--verified]] [--key <file>]", |
| 447 | Short: "mark an email verified by admin assertion", | 417 | Short: "create a user (host-local bootstrap; the only path in closed mode)", |
| 448 | Args: cobra.ExactArgs(2), | 418 | DisableFlagParsing: true, |
| 449 | RunE: func(cmd *cobra.Command, args []string) error { | 419 | RunE: func(cmd *cobra.Command, args []string) error { |
| 450 | cfg, err := config.Load(configPath) | 420 | var stdin io.Reader = os.Stdin |
| 451 | if err != nil { | 421 | var rest []string |
| 452 | return err | 422 | args = hostArgs(args) |
| 453 | } | 423 | for i := 0; i < len(args); i++ { |
| 454 | st, err := openStore(cfg) | 424 | switch { |
| 455 | if err != nil { | 425 | case args[i] == "--help" || args[i] == "-h": |
| 456 | return err | 426 | return cmd.Help() |
| 457 | } | 427 | case args[i] == "--key" && i+1 < len(args) && args[i+1] != "-": |
| 458 | defer st.Close() | 428 | f, err := os.Open(args[i+1]) |
| 459 | u, err := st.UserByUsername(args[0]) | 429 | if err != nil { |
| 460 | if err != nil { | 430 | return err |
| 461 | return fmt.Errorf("user %s: %w", args[0], err) | 431 | } |
| 462 | } | 432 | defer f.Close() |
| 463 | if err := st.VerifyEmail(u.ID, args[1], "admin"); err != nil { | 433 | stdin = f |
| 464 | st.Audit(0, "admin email.verify_failed", map[string]any{"user": args[0], "email": args[1]}) | 434 | rest = append(rest, "--key", "-") |
| 465 | return fmt.Errorf("no address %s on user %s", args[1], args[0]) | 435 | i++ |
| 436 | default: | ||
| 437 | rest = append(rest, args[i]) | ||
| 438 | } | ||
| 466 | } | 439 | } |
| 467 | st.Audit(0, "admin email.verified", map[string]any{"user": args[0], "email": args[1]}) | 440 | return runAsHost([]string{"admin", "user", "create"}, rest, stdin) |
| 468 | fmt.Println("verified", args[1]) | ||
| 469 | return nil | ||
| 470 | }, | 441 | }, |
| 471 | } | 442 | } |
| 472 | } | 443 | } |
cmd/gitbayd/maint.go −63
| @@ -1,11 +1,9 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | ||
| 5 | "fmt" | 4 | "fmt" |
| 6 | "os" | 5 | "os" |
| 7 | "os/exec" | 6 | "os/exec" |
| 8 | "text/tabwriter" | ||
| 9 | 7 | ||
| 10 | "github.com/spf13/cobra" | 8 | "github.com/spf13/cobra" |
| 11 | 9 | ||
| @@ -68,67 +66,6 @@ func gcCmd() *cobra.Command { | |||
| 68 | return cmd | 66 | return cmd |
| 69 | } | 67 | } |
| 70 | 68 | ||
| 71 | func statsCmd() *cobra.Command { | ||
| 72 | var asJSON bool | ||
| 73 | cmd := &cobra.Command{ | ||
| 74 | Use: "stats", | ||
| 75 | Short: "instance statistics: counts and per-repository disk usage", | ||
| 76 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 77 | cfg, err := config.Load(configPath) | ||
| 78 | if err != nil { | ||
| 79 | return err | ||
| 80 | } | ||
| 81 | st, err := openStore(cfg) | ||
| 82 | if err != nil { | ||
| 83 | return err | ||
| 84 | } | ||
| 85 | defer st.Close() | ||
| 86 | |||
| 87 | counts, err := st.InstanceCounts() | ||
| 88 | if err != nil { | ||
| 89 | return err | ||
| 90 | } | ||
| 91 | repos, err := st.ListAllRepos() | ||
| 92 | if err != nil { | ||
| 93 | return err | ||
| 94 | } | ||
| 95 | type repoDisk struct { | ||
| 96 | Path string `json:"path"` | ||
| 97 | Bytes int64 `json:"bytes"` | ||
| 98 | } | ||
| 99 | var disks []repoDisk | ||
| 100 | var totalDisk int64 | ||
| 101 | for _, r := range repos { | ||
| 102 | b := gitutil.DirSize(control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name)) | ||
| 103 | disks = append(disks, repoDisk{r.Path(), b}) | ||
| 104 | totalDisk += b | ||
| 105 | } | ||
| 106 | var dbBytes int64 | ||
| 107 | if fi, err := os.Stat(cfg.Server.Root + "/gitbay.db"); err == nil { | ||
| 108 | dbBytes = fi.Size() | ||
| 109 | } | ||
| 110 | |||
| 111 | if asJSON { | ||
| 112 | return json.NewEncoder(os.Stdout).Encode(map[string]any{ | ||
| 113 | "counts": counts, "db_bytes": dbBytes, | ||
| 114 | "repo_bytes": totalDisk, "repos": disks, | ||
| 115 | }) | ||
| 116 | } | ||
| 117 | fmt.Printf("users %d · orgs %d · repos %d · issues %d (%d open) · MRs %d (%d open)\n", | ||
| 118 | counts.Users, counts.Orgs, counts.Repos, | ||
| 119 | counts.Issues, counts.OpenIssues, counts.MRs, counts.OpenMRs) | ||
| 120 | fmt.Printf("database %s · repositories %s\n\n", human(dbBytes), human(totalDisk)) | ||
| 121 | w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) | ||
| 122 | for _, d := range disks { | ||
| 123 | fmt.Fprintf(w, "%s\t%s\n", d.Path, human(d.Bytes)) | ||
| 124 | } | ||
| 125 | return w.Flush() | ||
| 126 | }, | ||
| 127 | } | ||
| 128 | cmd.Flags().BoolVar(&asJSON, "json", false, "machine-readable output") | ||
| 129 | return cmd | ||
| 130 | } | ||
| 131 | |||
| 132 | func human(b int64) string { | 69 | func human(b int64) string { |
| 133 | switch { | 70 | switch { |
| 134 | case b >= 1<<30: | 71 | case b >= 1<<30: |
e2e/adminusers_test.go +68
| @@ -331,3 +331,71 @@ func TestAdminRepoModeration(t *testing.T) { | |||
| 331 | } | 331 | } |
| 332 | } | 332 | } |
| 333 | } | 333 | } |
| 334 | |||
| 335 | // The host-local admin commands dispatch into the registry, so the same | ||
| 336 | // commands work in an admin's SSH session and audit rows say which path | ||
| 337 | // ran them. | ||
| 338 | func TestAdminHostAndSSHAreOneSurface(t *testing.T) { | ||
| 339 | inst := startInstance(t) | ||
| 340 | rootKey := inst.newKey(t, "root") | ||
| 341 | aliceKey := inst.newKey(t, "alice") | ||
| 342 | carolKey := inst.newKey(t, "carol") | ||
| 343 | inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin") | ||
| 344 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | ||
| 345 | |||
| 346 | pub, err := os.ReadFile(carolKey + ".pub") | ||
| 347 | if err != nil { | ||
| 348 | t.Fatal(err) | ||
| 349 | } | ||
| 350 | out, errOut, code := inst.ssh(t, rootKey, string(pub), "admin", "user", "create", "carol", | ||
| 351 | "--email", "carol@example.test", "--verified", "--key", "-") | ||
| 352 | if code != 0 || !strings.Contains(out, "created user carol") || !strings.Contains(out, "key SHA256:") { | ||
| 353 | t.Fatalf("ssh user create: exit %d\n%s%s", code, out, errOut) | ||
| 354 | } | ||
| 355 | if _, _, code := inst.ssh(t, carolKey, "", "whoami"); code != 0 { | ||
| 356 | t.Fatal("created account cannot authenticate") | ||
| 357 | } | ||
| 358 | if _, errOut, code := inst.ssh(t, rootKey, "", "admin", "user", "create", "alice"); code != 2 || !strings.Contains(errOut, "taken") { | ||
| 359 | t.Fatalf("duplicate create: exit %d %s", code, errOut) | ||
| 360 | } | ||
| 361 | for _, args := range [][]string{{"admin", "stats"}, {"admin", "user", "disable", "carol"}, {"admin", "invite", "--email", "x@example.test"}} { | ||
| 362 | if _, _, code := inst.ssh(t, aliceKey, "", args...); code != 4 { | ||
| 363 | t.Fatalf("non-admin ran %v: exit %d", args, code) | ||
| 364 | } | ||
| 365 | } | ||
| 366 | if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "disable", "carol"); code != 0 { | ||
| 367 | t.Fatal("ssh disable failed") | ||
| 368 | } | ||
| 369 | if _, _, code := inst.ssh(t, carolKey, "", "whoami"); code != 4 { | ||
| 370 | t.Fatal("disabled account still authenticates") | ||
| 371 | } | ||
| 372 | inst.admin(t, "admin", "user", "enable", "carol") | ||
| 373 | if _, _, code := inst.ssh(t, carolKey, "", "whoami"); code != 0 { | ||
| 374 | t.Fatal("host enable did not take") | ||
| 375 | } | ||
| 376 | if out, _, code := inst.ssh(t, rootKey, "", "admin", "stats", "--json"); code != 0 || !strings.Contains(out, `"users":`) { | ||
| 377 | t.Fatalf("ssh stats: exit %d\n%s", code, out) | ||
| 378 | } | ||
| 379 | // No SMTP: the invite code comes back on stdout instead of by mail. | ||
| 380 | if out, _, code := inst.ssh(t, rootKey, "", "admin", "invite", "--email", "dave@example.test", "--json"); code != 0 || !strings.Contains(out, `"code":"`) { | ||
| 381 | t.Fatalf("ssh invite: exit %d\n%s", code, out) | ||
| 382 | } | ||
| 383 | if _, errOut, code := inst.ssh(t, rootKey, "", "admin", "email", "verify", "carol", "nope@example.test"); code != 3 || !strings.Contains(errOut, "no address") { | ||
| 384 | t.Fatalf("verify unknown address: exit %d %s", code, errOut) | ||
| 385 | } | ||
| 386 | if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "delete", "carol", "--yes"); code != 0 { | ||
| 387 | t.Fatal("ssh delete failed") | ||
| 388 | } | ||
| 389 | if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "delete", "root", "--yes"); code != 2 { | ||
| 390 | t.Fatal("deleted own account") | ||
| 391 | } | ||
| 392 | |||
| 393 | // Both paths audit under the same action names; the row says which | ||
| 394 | // credential ran it. | ||
| 395 | audit := inst.admin(t, "admin", "audit") | ||
| 396 | for _, want := range []string{`"source":"host"`, `"source":"SHA256:`, "admin user.created", "admin user.disabled", "admin user.enabled", "admin user.deleted"} { | ||
| 397 | if !strings.Contains(audit, want) { | ||
| 398 | t.Fatalf("audit lacks %q:\n%s", want, audit) | ||
| 399 | } | ||
| 400 | } | ||
| 401 | } | ||
internal/control/admin.go +1
| @@ -306,6 +306,7 @@ func setAdmin(c *Ctx, args []string, admin bool) int { | |||
| 306 | } | 306 | } |
| 307 | return c.fail(protocol.ExitFailure, "%v", err) | 307 | return c.fail(protocol.ExitFailure, "%v", err) |
| 308 | } | 308 | } |
| 309 | c.Store.Audit(c.User.ID, "admin user."+verb+"d", map[string]any{"user": u.Username}) | ||
| 309 | return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) { | 310 | return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) { |
| 310 | fmt.Fprintf(w, "%sd %s\n", verb, u.Username) | 311 | fmt.Fprintf(w, "%sd %s\n", verb, u.Username) |
| 311 | }) | 312 | }) |
internal/control/adminhost.go added +337
| @@ -0,0 +1,337 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | "os" | ||
| 8 | |||
| 9 | "golang.org/x/crypto/ssh" | ||
| 10 | |||
| 11 | "gitbay.org/gitbay/internal/gitutil" | ||
| 12 | "gitbay.org/gitbay/internal/mail" | ||
| 13 | "gitbay.org/gitbay/internal/policy" | ||
| 14 | "gitbay.org/gitbay/internal/protocol" | ||
| 15 | "gitbay.org/gitbay/internal/store" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // The account, email, invite and stats commands gitbayd admin used to | ||
| 19 | // implement on its own. They live here so the host binary and an admin | ||
| 20 | // session run the same code; gitbayd admin dispatches into these. | ||
| 21 | |||
| 22 | func init() { | ||
| 23 | register(Command{Path: []string{"admin", "user", "create"}, | ||
| 24 | Summary: "create an account, optionally with a key and a verified address (instance admins)", | ||
| 25 | Usage: "admin user create <username> [--admin] [--email <address> [--verified]] [--key -] < key.pub", | ||
| 26 | ReadsStdin: true, SSHOnly: true, Run: runAdminUserCreate}) | ||
| 27 | register(Command{Path: []string{"admin", "user", "disable"}, | ||
| 28 | Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled", | ||
| 29 | Usage: "admin user disable <username>", | ||
| 30 | SSHOnly: true, Run: runAdminUserDisable}) | ||
| 31 | register(Command{Path: []string{"admin", "user", "enable"}, | ||
| 32 | Summary: "restore a suspended account", | ||
| 33 | Usage: "admin user enable <username>", | ||
| 34 | SSHOnly: true, Run: runAdminUserEnable}) | ||
| 35 | register(Command{Path: []string{"admin", "user", "delete"}, | ||
| 36 | Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)", | ||
| 37 | Usage: "admin user delete <username> --yes", | ||
| 38 | SSHOnly: true, Run: runAdminUserDelete}) | ||
| 39 | register(Command{Path: []string{"admin", "email", "verify"}, | ||
| 40 | Summary: "mark an address verified by admin assertion", | ||
| 41 | Usage: "admin email verify <username> <address>", | ||
| 42 | SSHOnly: true, Run: runAdminEmailVerify}) | ||
| 43 | register(Command{Path: []string{"admin", "invite"}, | ||
| 44 | Summary: "issue a registration invite and mail its code", | ||
| 45 | Usage: "admin invite --email <address>", | ||
| 46 | SSHOnly: true, Run: runAdminInvite}) | ||
| 47 | register(Command{Path: []string{"admin", "stats"}, | ||
| 48 | Summary: "instance statistics: counts and per-repository disk usage", | ||
| 49 | Usage: "admin stats", | ||
| 50 | ReadOnly: true, SSHOnly: true, Run: runAdminStats}) | ||
| 51 | } | ||
| 52 | |||
| 53 | func runAdminUserCreate(c *Ctx, args []string) int { | ||
| 54 | if code := requireInstanceAdmin(c); code >= 0 { | ||
| 55 | return code | ||
| 56 | } | ||
| 57 | const usage = "usage: admin user create <username> [--admin] [--email <address> [--verified]] [--key -] < key.pub" | ||
| 58 | var username, email string | ||
| 59 | var isAdmin, verified, withKey bool | ||
| 60 | for i := 0; i < len(args); i++ { | ||
| 61 | switch args[i] { | ||
| 62 | case "--admin": | ||
| 63 | isAdmin = true | ||
| 64 | case "--verified": | ||
| 65 | verified = true | ||
| 66 | case "--email": | ||
| 67 | if i+1 >= len(args) { | ||
| 68 | return c.fail(protocol.ExitUsage, "--email requires a value") | ||
| 69 | } | ||
| 70 | email = args[i+1] | ||
| 71 | i++ | ||
| 72 | case "--key": | ||
| 73 | if i+1 >= len(args) || args[i+1] != "-" { | ||
| 74 | return c.fail(protocol.ExitUsage, "--key only supports - (the public key on stdin)") | ||
| 75 | } | ||
| 76 | withKey = true | ||
| 77 | i++ | ||
| 78 | default: | ||
| 79 | if username != "" || len(args[i]) == 0 || args[i][0] == '-' { | ||
| 80 | return c.fail(protocol.ExitUsage, usage) | ||
| 81 | } | ||
| 82 | username = args[i] | ||
| 83 | } | ||
| 84 | } | ||
| 85 | if username == "" || (verified && email == "") { | ||
| 86 | return c.fail(protocol.ExitUsage, usage) | ||
| 87 | } | ||
| 88 | if err := policy.ValidateOwnerName(username); err != nil { | ||
| 89 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 90 | } | ||
| 91 | // Parse the key before creating anything, so a bad key leaves no | ||
| 92 | // half-made account behind. | ||
| 93 | var pub ssh.PublicKey | ||
| 94 | if withKey { | ||
| 95 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) | ||
| 96 | if err != nil { | ||
| 97 | return c.fail(protocol.ExitFailure, "reading key: %v", err) | ||
| 98 | } | ||
| 99 | if pub, _, _, _, err = ssh.ParseAuthorizedKey(raw); err != nil { | ||
| 100 | return c.fail(protocol.ExitUsage, "not a public key in authorized_keys format: %v", err) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | uid, err := c.Store.CreateUser(username, isAdmin) | ||
| 104 | if err != nil { | ||
| 105 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 106 | } | ||
| 107 | if email != "" { | ||
| 108 | by := "" | ||
| 109 | if verified { | ||
| 110 | by = "admin" | ||
| 111 | } | ||
| 112 | if err := c.Store.AddEmail(uid, email, by, true); err != nil { | ||
| 113 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 114 | } | ||
| 115 | } | ||
| 116 | fp := "" | ||
| 117 | if pub != nil { | ||
| 118 | fp = ssh.FingerprintSHA256(pub) | ||
| 119 | if err := c.Store.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil { | ||
| 120 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 121 | } | ||
| 122 | } | ||
| 123 | c.Store.Audit(c.User.ID, "admin user.created", map[string]any{"user": username}) | ||
| 124 | type out struct { | ||
| 125 | User string `json:"user"` | ||
| 126 | Admin bool `json:"admin,omitempty"` | ||
| 127 | Fingerprint string `json:"fingerprint,omitempty"` | ||
| 128 | } | ||
| 129 | return c.emit(out{username, isAdmin, fp}, func(w io.Writer) { | ||
| 130 | if fp != "" { | ||
| 131 | fmt.Fprintln(w, "key", fp) | ||
| 132 | } | ||
| 133 | fmt.Fprintln(w, "created user", username) | ||
| 134 | }) | ||
| 135 | } | ||
| 136 | |||
| 137 | // adminUserArg resolves the single username argument of an admin command. | ||
| 138 | func adminUserArg(c *Ctx, args []string, usage string) (store.User, int) { | ||
| 139 | if code := requireInstanceAdmin(c); code >= 0 { | ||
| 140 | return store.User{}, code | ||
| 141 | } | ||
| 142 | if len(args) != 1 { | ||
| 143 | return store.User{}, c.fail(protocol.ExitUsage, "usage: %s", usage) | ||
| 144 | } | ||
| 145 | u, err := c.Store.UserByUsername(args[0]) | ||
| 146 | if errors.Is(err, store.ErrNotFound) { | ||
| 147 | return u, c.fail(protocol.ExitNotFound, "no user %q", args[0]) | ||
| 148 | } else if err != nil { | ||
| 149 | return u, c.fail(protocol.ExitFailure, "%v", err) | ||
| 150 | } | ||
| 151 | return u, -1 | ||
| 152 | } | ||
| 153 | |||
| 154 | func runAdminUserDisable(c *Ctx, args []string) int { | ||
| 155 | u, code := adminUserArg(c, args, "admin user disable <username>") | ||
| 156 | if code >= 0 { | ||
| 157 | return code | ||
| 158 | } | ||
| 159 | if err := c.Store.SetUserDisabled(u.ID, true); err != nil { | ||
| 160 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 161 | } | ||
| 162 | c.Store.Audit(c.User.ID, "admin user.disabled", map[string]any{"user": u.Username}) | ||
| 163 | return c.emit(map[string]any{"user": u.Username, "disabled": true}, func(w io.Writer) { | ||
| 164 | fmt.Fprintf(w, "disabled %s: SSH, web sessions, and API tokens are refused; nothing was deleted\n", u.Username) | ||
| 165 | }) | ||
| 166 | } | ||
| 167 | |||
| 168 | func runAdminUserEnable(c *Ctx, args []string) int { | ||
| 169 | u, code := adminUserArg(c, args, "admin user enable <username>") | ||
| 170 | if code >= 0 { | ||
| 171 | return code | ||
| 172 | } | ||
| 173 | if err := c.Store.SetUserDisabled(u.ID, false); err != nil { | ||
| 174 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 175 | } | ||
| 176 | c.Store.Audit(c.User.ID, "admin user.enabled", map[string]any{"user": u.Username}) | ||
| 177 | return c.emit(map[string]any{"user": u.Username, "disabled": false}, func(w io.Writer) { | ||
| 178 | fmt.Fprintf(w, "enabled %s\n", u.Username) | ||
| 179 | }) | ||
| 180 | } | ||
| 181 | |||
| 182 | func runAdminUserDelete(c *Ctx, args []string) int { | ||
| 183 | var rest []string | ||
| 184 | var yes bool | ||
| 185 | for _, a := range args { | ||
| 186 | if a == "--yes" { | ||
| 187 | yes = true | ||
| 188 | } else { | ||
| 189 | rest = append(rest, a) | ||
| 190 | } | ||
| 191 | } | ||
| 192 | u, code := adminUserArg(c, rest, "admin user delete <username> --yes") | ||
| 193 | if code >= 0 { | ||
| 194 | return code | ||
| 195 | } | ||
| 196 | if !yes { | ||
| 197 | return c.fail(protocol.ExitUsage, "deletion is permanent; pass --yes") | ||
| 198 | } | ||
| 199 | if u.ID == c.User.ID { | ||
| 200 | return c.fail(protocol.ExitUsage, "that is your own account") | ||
| 201 | } | ||
| 202 | if err := c.Store.DeleteUser(u.ID); err != nil { | ||
| 203 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 204 | } | ||
| 205 | c.Store.Audit(c.User.ID, "admin user.deleted", map[string]any{"user": u.Username}) | ||
| 206 | return c.emit(map[string]string{"deleted": u.Username}, func(w io.Writer) { | ||
| 207 | fmt.Fprintf(w, "deleted %s\n", u.Username) | ||
| 208 | }) | ||
| 209 | } | ||
| 210 | |||
| 211 | func runAdminEmailVerify(c *Ctx, args []string) int { | ||
| 212 | if code := requireInstanceAdmin(c); code >= 0 { | ||
| 213 | return code | ||
| 214 | } | ||
| 215 | if len(args) != 2 { | ||
| 216 | return c.fail(protocol.ExitUsage, "usage: admin email verify <username> <address>") | ||
| 217 | } | ||
| 218 | u, err := c.Store.UserByUsername(args[0]) | ||
| 219 | if errors.Is(err, store.ErrNotFound) { | ||
| 220 | return c.fail(protocol.ExitNotFound, "no user %q", args[0]) | ||
| 221 | } else if err != nil { | ||
| 222 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 223 | } | ||
| 224 | if err := c.Store.VerifyEmail(u.ID, args[1], "admin"); err != nil { | ||
| 225 | c.Store.Audit(c.User.ID, "admin email.verify_failed", map[string]any{"user": args[0], "email": args[1]}) | ||
| 226 | return c.fail(protocol.ExitNotFound, "no address %s on user %s", args[1], args[0]) | ||
| 227 | } | ||
| 228 | c.Store.Audit(c.User.ID, "admin email.verified", map[string]any{"user": args[0], "email": args[1]}) | ||
| 229 | return c.emit(map[string]string{"user": args[0], "verified": args[1]}, func(w io.Writer) { | ||
| 230 | fmt.Fprintln(w, "verified", args[1]) | ||
| 231 | }) | ||
| 232 | } | ||
| 233 | |||
| 234 | func runAdminInvite(c *Ctx, args []string) int { | ||
| 235 | if code := requireInstanceAdmin(c); code >= 0 { | ||
| 236 | return code | ||
| 237 | } | ||
| 238 | email := "" | ||
| 239 | if len(args) == 2 && args[0] == "--email" { | ||
| 240 | email = args[1] | ||
| 241 | } | ||
| 242 | if email == "" { | ||
| 243 | return c.fail(protocol.ExitUsage, "usage: admin invite --email <address>") | ||
| 244 | } | ||
| 245 | if used, err := c.Store.EmailInUse(email); err != nil { | ||
| 246 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 247 | } else if used { | ||
| 248 | return c.fail(protocol.ExitUsage, "%s already belongs to an account; invites are for new users", email) | ||
| 249 | } | ||
| 250 | code, hash, err := store.NewToken() | ||
| 251 | if err != nil { | ||
| 252 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 253 | } | ||
| 254 | if err := c.Store.CreateInvite(hash, email); err != nil { | ||
| 255 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 256 | } | ||
| 257 | host := siteHost(c.Cfg) | ||
| 258 | body := fmt.Sprintf( | ||
| 259 | "You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+ | ||
| 260 | " ssh git@%s register --username <name> --invite %s\n\n"+ | ||
| 261 | "The invite is single-use and tied to this address.\n", host, host, code) | ||
| 262 | type out struct { | ||
| 263 | Email string `json:"email"` | ||
| 264 | Mailed bool `json:"mailed"` | ||
| 265 | Code string `json:"code,omitempty"` // only when it could not be mailed | ||
| 266 | } | ||
| 267 | if c.Cfg.Mail.SMTPHost != "" { | ||
| 268 | if err := mail.Send(c.Cfg, email, "your invite to "+host, body); err != nil { | ||
| 269 | return c.fail(protocol.ExitFailure, "invite stored but mail failed: %v (code: %s)", err, code) | ||
| 270 | } | ||
| 271 | c.Store.Audit(c.User.ID, "admin invite.issued", map[string]any{"email": email}) | ||
| 272 | return c.emit(out{Email: email, Mailed: true}, func(w io.Writer) { | ||
| 273 | fmt.Fprintf(w, "invite emailed to %s\n", email) | ||
| 274 | }) | ||
| 275 | } | ||
| 276 | return c.emit(out{Email: email, Code: code}, func(w io.Writer) { | ||
| 277 | fmt.Fprintf(w, "invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code) | ||
| 278 | }) | ||
| 279 | } | ||
| 280 | |||
| 281 | func runAdminStats(c *Ctx, args []string) int { | ||
| 282 | if code := requireInstanceAdmin(c); code >= 0 { | ||
| 283 | return code | ||
| 284 | } | ||
| 285 | if len(args) != 0 { | ||
| 286 | return c.fail(protocol.ExitUsage, "usage: admin stats") | ||
| 287 | } | ||
| 288 | counts, err := c.Store.InstanceCounts() | ||
| 289 | if err != nil { | ||
| 290 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 291 | } | ||
| 292 | repos, err := c.Store.ListAllRepos() | ||
| 293 | if err != nil { | ||
| 294 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 295 | } | ||
| 296 | type repoDisk struct { | ||
| 297 | Path string `json:"path"` | ||
| 298 | Bytes int64 `json:"bytes"` | ||
| 299 | } | ||
| 300 | type out struct { | ||
| 301 | Counts store.Counts `json:"counts"` | ||
| 302 | DBBytes int64 `json:"db_bytes"` | ||
| 303 | RepoBytes int64 `json:"repo_bytes"` | ||
| 304 | Repos []repoDisk `json:"repos"` | ||
| 305 | } | ||
| 306 | d := out{Counts: counts, Repos: []repoDisk{}} | ||
| 307 | for _, r := range repos { | ||
| 308 | b := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) | ||
| 309 | d.Repos = append(d.Repos, repoDisk{r.Path(), b}) | ||
| 310 | d.RepoBytes += b | ||
| 311 | } | ||
| 312 | if fi, err := os.Stat(c.Cfg.Server.Root + "/gitbay.db"); err == nil { | ||
| 313 | d.DBBytes = fi.Size() | ||
| 314 | } | ||
| 315 | return c.emit(d, func(w io.Writer) { | ||
| 316 | fmt.Fprintf(w, "users %d · orgs %d · repos %d · issues %d (%d open) · MRs %d (%d open)\n", | ||
| 317 | counts.Users, counts.Orgs, counts.Repos, | ||
| 318 | counts.Issues, counts.OpenIssues, counts.MRs, counts.OpenMRs) | ||
| 319 | fmt.Fprintf(w, "database %s · repositories %s\n\n", humanBytes(d.DBBytes), humanBytes(d.RepoBytes)) | ||
| 320 | for _, r := range d.Repos { | ||
| 321 | fmt.Fprintf(w, "%s\t%s\n", r.Path, humanBytes(r.Bytes)) | ||
| 322 | } | ||
| 323 | }) | ||
| 324 | } | ||
| 325 | |||
| 326 | func humanBytes(b int64) string { | ||
| 327 | switch { | ||
| 328 | case b >= 1<<30: | ||
| 329 | return fmt.Sprintf("%.1f GiB", float64(b)/(1<<30)) | ||
| 330 | case b >= 1<<20: | ||
| 331 | return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20)) | ||
| 332 | case b >= 1<<10: | ||
| 333 | return fmt.Sprintf("%.1f KiB", float64(b)/(1<<10)) | ||
| 334 | default: | ||
| 335 | return fmt.Sprintf("%d B", b) | ||
| 336 | } | ||
| 337 | } | ||