internal/control/adminhost.go

6821a6f76082b1e10ff899ff51021b11695c4ad6
gitbay/internal/control/adminhost.go history · blame · raw

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