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