internal/control/adminhost.go
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}