internal/control/adminhost.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/control/adminhost.go history · blame · raw

390 lines · 13603 bytes

10 symbols in this file
  1package control
  2
  3import (
  4	"cmp"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"os"
  9	"slices"
 10
 11	"golang.org/x/crypto/ssh"
 12
 13	"gitbay.org/gitbay/internal/gitutil"
 14	"gitbay.org/gitbay/internal/lfs"
 15	"gitbay.org/gitbay/internal/mail"
 16	"gitbay.org/gitbay/internal/policy"
 17	"gitbay.org/gitbay/internal/protocol"
 18	"gitbay.org/gitbay/internal/store"
 19)
 20
 21// The account, email, invite and stats commands gitbayd admin used to
 22// implement on its own. They live here so the host binary and an admin
 23// session run the same code; gitbayd admin dispatches into these.
 24
 25func init() {
 26	register(Command{Path: []string{"admin", "user", "create"},
 27		Summary: "create an account, optionally with a key and a verified address (instance admins)",
 28		Usage:   "admin user create <username> [--admin] [--email <address> [--verified]] [--key -] < key.pub",
 29		Flags: []Flag{
 30			{"--admin", "", "make the account an instance admin", ""},
 31			{"--email", "<address>", "an address to add", ""},
 32			{"--verified", "", "mark that address verified", ""},
 33			{"--key", "-", "read a public key from stdin", ""},
 34		},
 35		Examples:        []string{"admin user create alice --email alice@example.org --key - < key.pub"},
 36		ReadsStdin:      true,
 37		MintsCredential: true, NeedsRecentSignIn: true, Run: runAdminUserCreate})
 38	register(Command{Path: []string{"admin", "user", "disable"},
 39		Summary:  "suspend an account: SSH, web sessions and API tokens refused until re-enabled",
 40		Usage:    "admin user disable <username>",
 41		Examples: []string{"admin user disable alice"},
 42		Run:      runAdminUserDisable})
 43	register(Command{Path: []string{"admin", "user", "enable"},
 44		NeedsRecentSignIn: true,
 45		Summary:           "restore a suspended account",
 46		Usage:             "admin user enable <username>",
 47		Examples:          []string{"admin user enable alice"},
 48		Run:               runAdminUserEnable})
 49	register(Command{Path: []string{"admin", "user", "delete"},
 50		Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)",
 51		Usage:   "admin user delete <username> --yes",
 52		Flags: []Flag{
 53			{"--yes", "", "confirm the permanent delete", ""},
 54		},
 55		Examples: []string{"admin user delete alice --yes"},
 56		Run:      runAdminUserDelete})
 57	register(Command{Path: []string{"admin", "email", "verify"},
 58		Summary:         "mark an address verified by admin assertion",
 59		Usage:           "admin email verify <username> <address>",
 60		Examples:        []string{"admin email verify alice alice@example.org"},
 61		MintsCredential: true, NeedsRecentSignIn: true,
 62		Run: runAdminEmailVerify})
 63	register(Command{Path: []string{"admin", "invite"},
 64		Summary: "issue a registration invite and mail its code",
 65		Usage:   "admin invite --email <address>",
 66		Flags: []Flag{
 67			{"--email", "<address>", "who the invite is for", ""},
 68		},
 69		Examples:        []string{"admin invite --email alice@example.org"},
 70		MintsCredential: true, NeedsRecentSignIn: true,
 71		Run: runAdminInvite})
 72	register(Command{Path: []string{"admin", "stats"},
 73		Summary:  "instance statistics: counts and per-repository disk usage",
 74		Usage:    "admin stats",
 75		Examples: []string{"admin stats"},
 76		ReadOnly: true, Run: runAdminStats})
 77}
 78
 79func runAdminUserCreate(c *Ctx, args []string) int {
 80	if code := requireInstanceAdmin(c); code >= 0 {
 81		return code
 82	}
 83	f, err := c.parseArgs(args, flagSpec{Values: []string{"--email", "--key"}, Bools: []string{"--admin", "--verified"}, MaxPos: 1, Usage: c.Cmd.Usage})
 84	if err != nil {
 85		return c.fail(protocol.ExitUsage, "%v", err)
 86	}
 87	username, email := f.pos(0), f.Value("--email")
 88	isAdmin, verified, withKey := f.Has("--admin"), f.Has("--verified"), f.Has("--key")
 89	if withKey && f.Value("--key") != "-" {
 90		return c.fail(protocol.ExitUsage, "--key only supports - (the public key on stdin)")
 91	}
 92	if username == "" || username[0] == '-' {
 93		return c.usage()
 94	}
 95	if username == "" || (verified && email == "") {
 96		return c.usage()
 97	}
 98	if err := policy.ValidateOwnerName(username); err != nil {
 99		return c.failInput(err)
100	}
101	// Parse the key before creating anything, so a bad key leaves no
102	// half-made account behind.
103	var pub ssh.PublicKey
104	var comment string
105	if withKey {
106		raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
107		if err != nil {
108			return c.fail(protocol.ExitFailure, "reading key: %v", err)
109		}
110		if pub, comment, _, _, err = ssh.ParseAuthorizedKey(raw); err != nil {
111			return c.fail(protocol.ExitUsage, "not a public key in authorized_keys format: %v", err)
112		}
113	}
114	uid, err := c.Store.CreateUser(username, isAdmin)
115	if err != nil {
116		return c.failErr(err)
117	}
118	if email != "" {
119		by := ""
120		if verified {
121			by = "admin"
122		}
123		if err := c.Store.AddEmail(uid, email, by, true); err != nil {
124			return c.failErr(err)
125		}
126	}
127	fp := ""
128	if pub != nil {
129		fp = ssh.FingerprintSHA256(pub)
130		label, _ := keyLabel(comment)
131		if err := c.Store.AddSSHKeyFrom(uid, fp, pub.Type(), pub.Marshal(), "full", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
132			return c.failErr(err)
133		}
134	}
135	c.Store.Audit(c.User.ID, "admin user.created", map[string]any{"user": username})
136	type out struct {
137		User        string `json:"user"`
138		Admin       bool   `json:"admin,omitempty"`
139		Fingerprint string `json:"fingerprint,omitempty"`
140	}
141	return c.emit(out{username, isAdmin, fp}, func(w io.Writer) {
142		if fp != "" {
143			fmt.Fprintln(w, "key", fp)
144		}
145		fmt.Fprintln(w, "created user", username)
146	})
147}
148
149// adminUserArg resolves the single username argument of an admin command.
150func adminUserArg(c *Ctx, args []string, usage string) (store.User, int) {
151	if code := requireInstanceAdmin(c); code >= 0 {
152		return store.User{}, code
153	}
154	if len(args) != 1 {
155		return store.User{}, c.usage()
156	}
157	u, err := c.Store.UserByUsername(args[0])
158	if errors.Is(err, store.ErrNotFound) {
159		return u, c.fail(protocol.ExitNotFound, "no user %q", args[0])
160	} else if err != nil {
161		return u, c.fail(protocol.ExitFailure, "%v", err)
162	}
163	if u.Ghost {
164		return u, c.fail(protocol.ExitDenied, "%v", errGhost)
165	}
166	return u, -1
167}
168
169func runAdminUserDisable(c *Ctx, args []string) int {
170	u, code := adminUserArg(c, args, "admin user disable <username>")
171	if code >= 0 {
172		return code
173	}
174	if err := c.Store.SetUserDisabled(u.ID, true); err != nil {
175		return c.fail(protocol.ExitFailure, "%v", err)
176	}
177	c.Store.Audit(c.User.ID, "admin user.disabled", map[string]any{"user": u.Username})
178	return c.emit(map[string]any{"user": u.Username, "disabled": true}, func(w io.Writer) {
179		fmt.Fprintf(w, "disabled %s: SSH, web sessions, and API tokens are refused; nothing was deleted\n", u.Username)
180	})
181}
182
183func runAdminUserEnable(c *Ctx, args []string) int {
184	u, code := adminUserArg(c, args, "admin user enable <username>")
185	if code >= 0 {
186		return code
187	}
188	if err := c.Store.SetUserDisabled(u.ID, false); err != nil {
189		return c.fail(protocol.ExitFailure, "%v", err)
190	}
191	c.Store.Audit(c.User.ID, "admin user.enabled", map[string]any{"user": u.Username})
192	return c.emit(map[string]any{"user": u.Username, "disabled": false}, func(w io.Writer) {
193		fmt.Fprintf(w, "enabled %s\n", u.Username)
194	})
195}
196
197func runAdminUserDelete(c *Ctx, args []string) int {
198	var rest []string
199	var yes bool
200	for _, a := range args {
201		if a == "--yes" {
202			yes = true
203		} else {
204			rest = append(rest, a)
205		}
206	}
207	u, code := adminUserArg(c, rest, "admin user delete <username> --yes")
208	if code >= 0 {
209		return code
210	}
211	if !yes {
212		return c.fail(protocol.ExitUsage, "deletion is permanent; pass --yes")
213	}
214	if u.ID == c.User.ID {
215		return c.fail(protocol.ExitUsage, "that is your own account")
216	}
217	if err := c.Store.DeleteUser(u.ID); err != nil {
218		return c.failErr(err)
219	}
220	c.Store.Audit(c.User.ID, "admin user.deleted", map[string]any{"user": u.Username})
221	return c.emit(map[string]string{"deleted": u.Username}, func(w io.Writer) {
222		fmt.Fprintf(w, "deleted %s\n", u.Username)
223	})
224}
225
226func runAdminEmailVerify(c *Ctx, args []string) int {
227	if code := requireInstanceAdmin(c); code >= 0 {
228		return code
229	}
230	if len(args) != 2 {
231		return c.usage()
232	}
233	u, err := c.Store.UserByUsername(args[0])
234	if errors.Is(err, store.ErrNotFound) {
235		return c.fail(protocol.ExitNotFound, "no user %q", args[0])
236	} else if err != nil {
237		return c.fail(protocol.ExitFailure, "%v", err)
238	}
239	if err := c.Store.VerifyEmail(u.ID, args[1], "admin"); err != nil {
240		c.Store.Audit(c.User.ID, "admin email.verify_failed", map[string]any{"user": args[0], "email": args[1]})
241		return c.fail(protocol.ExitNotFound, "no address %s on user %s", args[1], args[0])
242	}
243	c.Store.Audit(c.User.ID, "admin email.verified", map[string]any{"user": args[0], "email": args[1]})
244	return c.emit(map[string]string{"user": args[0], "verified": args[1]}, func(w io.Writer) {
245		fmt.Fprintln(w, "verified", args[1])
246	})
247}
248
249func runAdminInvite(c *Ctx, args []string) int {
250	if code := requireInstanceAdmin(c); code >= 0 {
251		return code
252	}
253	email := ""
254	if len(args) == 2 && args[0] == "--email" {
255		email = args[1]
256	}
257	if email == "" {
258		return c.usage()
259	}
260	if used, err := c.Store.EmailInUse(email); err != nil {
261		return c.fail(protocol.ExitFailure, "%v", err)
262	} else if used {
263		return c.fail(protocol.ExitUsage, "%s already belongs to an account; invites are for new users", email)
264	}
265	code, hash, err := store.NewToken()
266	if err != nil {
267		return c.fail(protocol.ExitFailure, "%v", err)
268	}
269	if err := c.Store.CreateInvite(hash, email); err != nil {
270		return c.fail(protocol.ExitFailure, "%v", err)
271	}
272	host := siteHost(c.Cfg)
273	body := fmt.Sprintf(
274		"You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+
275			"    ssh git@%s register --username <name> --invite %s\n\n"+
276			"The invite is single-use and tied to this address.\n", host, host, code)
277	type out struct {
278		Email  string `json:"email"`
279		Mailed bool   `json:"mailed"`
280		Code   string `json:"code,omitempty"` // only when it could not be mailed
281	}
282	if c.Cfg.Mail.SMTPHost != "" {
283		if err := mail.Send(c.Cfg, email, "your invite to "+host, body); err != nil {
284			return c.fail(protocol.ExitFailure, "invite stored but mail failed: %v (code: %s)", err, code)
285		}
286		c.Store.Audit(c.User.ID, "admin invite.issued", map[string]any{"email": email})
287		return c.emit(out{Email: email, Mailed: true}, func(w io.Writer) {
288			fmt.Fprintf(w, "invite emailed to %s\n", email)
289		})
290	}
291	return c.emit(out{Email: email, Code: code}, func(w io.Writer) {
292		fmt.Fprintf(w, "invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code)
293	})
294}
295
296func runAdminStats(c *Ctx, args []string) int {
297	if code := requireInstanceAdmin(c); code >= 0 {
298		return code
299	}
300	if len(args) != 0 {
301		return c.usage()
302	}
303	counts, err := c.Store.InstanceCounts()
304	if err != nil {
305		return c.fail(protocol.ExitFailure, "%v", err)
306	}
307	repos, err := c.Store.ListAllRepos()
308	if err != nil {
309		return c.fail(protocol.ExitFailure, "%v", err)
310	}
311	type repoDisk struct {
312		Path  string `json:"path"`
313		Bytes int64  `json:"bytes"`
314	}
315	type out struct {
316		Counts    store.Counts `json:"counts"`
317		DBBytes   int64        `json:"db_bytes"`
318		RepoBytes int64        `json:"repo_bytes"`
319		LFSBytes  int64        `json:"lfs_bytes"`
320		Repos     []repoDisk   `json:"repos"`
321	}
322	d := out{Counts: counts, Repos: []repoDisk{}}
323	d.LFSBytes = lfs.LocalStore{Root: lfs.RootFor(c.Cfg.LFS.Root, c.Cfg.Server.Root)}.Size()
324	for _, r := range repos {
325		b := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
326		d.Repos = append(d.Repos, repoDisk{r.Path(), b})
327		d.RepoBytes += b
328	}
329	if fi, err := os.Stat(c.Cfg.Server.Root + "/gitbay.db"); err == nil {
330		d.DBBytes = fi.Size()
331	}
332	return c.emitView(d, func(w io.Writer) {
333		v := c.view(w)
334		v.fields(
335			"users", fmt.Sprintf("%d", counts.Users),
336			"orgs", fmt.Sprintf("%d", counts.Orgs),
337			"repos", fmt.Sprintf("%d", counts.Repos),
338			"issues", fmt.Sprintf("%d (%d open)", counts.Issues, counts.OpenIssues),
339			"MRs", fmt.Sprintf("%d (%d open)", counts.MRs, counts.OpenMRs),
340			"database", humanBytes(d.DBBytes),
341			"repositories", humanBytes(d.RepoBytes),
342			"lfs", humanBytes(d.LFSBytes),
343		)
344		if len(d.Repos) > 0 {
345			v.section("repos")
346			tb := c.table(w, "PATH", "BYTES")
347			for _, r := range d.Repos {
348				tb.row(cRef(r.Path), cText(humanBytes(r.Bytes)))
349			}
350			tb.flush()
351		}
352	}, func() screen {
353		count := func(all, open int64) []cell {
354			return []cell{cText(fmt.Sprint(all)), cMeta(fmt.Sprintf("%d open", open))}
355		}
356		s := screen{fields: []field{
357			{"Users", []cell{cText(fmt.Sprint(counts.Users))}},
358			{"Orgs", []cell{cText(fmt.Sprint(counts.Orgs))}},
359			{"Repos", []cell{cText(fmt.Sprint(counts.Repos))}},
360			{"Issues", count(counts.Issues, counts.OpenIssues)},
361			{"MRs", count(counts.MRs, counts.OpenMRs)},
362			{"Disk", []cell{cSize(d.DBBytes), cMeta("database", "repositories "+humanBytes(d.RepoBytes), "lfs "+humanBytes(d.LFSBytes))}},
363		}}
364		repos := slices.Clone(d.Repos)
365		slices.SortStableFunc(repos, func(a, b repoDisk) int { return cmp.Compare(b.Bytes, a.Bytes) })
366		top := section{title: "Repositories", n: len(repos), more: []string{"admin", "repo", "list"}}
367		for _, r := range repos[:min(20, len(repos))] {
368			top.rows = append(top.rows, rowOf(cLink(r.Path, c.siteURL(r.Path)), cSize(r.Bytes)))
369		}
370		s.sections = []section{top}
371		s.actions = []action{
372			{"Admin", []string{"admin", "user", "list"}},
373			{"Admin", []string{"admin", "runners"}},
374		}
375		return s
376	})
377}
378
379func humanBytes(b int64) string {
380	switch {
381	case b >= 1<<30:
382		return fmt.Sprintf("%.1f GiB", float64(b)/(1<<30))
383	case b >= 1<<20:
384		return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20))
385	case b >= 1<<10:
386		return fmt.Sprintf("%.1f KiB", float64(b)/(1<<10))
387	default:
388		return fmt.Sprintf("%d B", b)
389	}
390}