internal/control/adminhost.go

39535a8e16d8dfc0189bce59511cfb0d7d019fb5
gitbay/internal/control/adminhost.go history · blame · raw

337 lines · 11502 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/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
 22func 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
 53func 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.
138func 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
154func 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
168func 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
182func 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
211func 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
234func 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
281func 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
326func 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}