internal/control/adminhost.go

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

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