internal/control/adminhost.go

v1.36.0
gitbay/internal/control/adminhost.go history · blame · raw

357 lines · 12113 bytes

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