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