internal/control/adminhost.go
390 lines · 13603 bytes
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}