internal/control/control.go
452 lines · 16494 bytes
1// Package control implements the forge control commands executed over SSH.
2// Every command here is reachable from bare OpenSSH: argv in, JSON or plain
3// text on stdout, diagnostics on stderr, exit code out.
4package control
5
6import (
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "reflect"
12 "slices"
13 "strings"
14 "time"
15
16 "gitbay.org/gitbay/internal/config"
17 "gitbay.org/gitbay/internal/packlimit"
18 "gitbay.org/gitbay/internal/protocol"
19 "gitbay.org/gitbay/internal/store"
20)
21
22type Ctx struct {
23 User store.User
24 Scope string // scope of the key that authenticated this session
25 Store *store.Store
26 Cfg config.Config
27 Stdin io.Reader
28 Stdout io.Writer
29 Stderr io.Writer
30 JSON bool
31 // Term is the client's terminal, from GITBAY_TERM. The zero value
32 // is plain output.
33 Term Term
34 // CLIPath is the path the gitbay CLI resolved this call to, from a
35 // leading --path=, when it differs from the registered path being
36 // dispatched (auth keys remove for keys remove). Usage and help
37 // print it in place of the registered path (#267). Empty for stock
38 // ssh, the web and the API.
39 CLIPath string
40 // ViaAPI marks requests arriving over HTTP, from the token API or
41 // the web. Every command runs there; nothing is held back for SSH
42 // any more (#234). The flag stays because the rate limiter and the
43 // audit log want to know which door a request came through.
44 ViaAPI bool
45 // ReadOnly is set for read-scoped API tokens.
46 ReadOnly bool
47 // Source identifies the credential behind this session for the audit
48 // log: an SSH key fingerprint, or "api" for token requests.
49 Source string
50 // TokenID is the API token behind this request, 0 for none. A
51 // credential the request creates records it.
52 TokenID int64
53 // Expires is when the credential behind this request lapses; nil
54 // when it does not. Dispatch refuses MintsCredential commands when
55 // it is set.
56 Expires *time.Time
57 // Cmd is the command being run, set by Dispatch, so a usage error can
58 // print the registered usage rather than a copy of it.
59 Cmd Command
60 // Argv is the command's arguments after its path, global flags
61 // removed, so output can print a command to run next.
62 Argv []string
63 // Done, when the surface has one, closes when nobody is reading any
64 // more: the SSH channel closed or the HTTP request ended. A command
65 // that runs until something happens (build log --follow) stops on it.
66 Done <-chan struct{}
67 // Stopping, when the surface has one, closes when the daemon is
68 // restarting. It closes Done too; a command that ends on Done checks
69 // it to say why.
70 Stopping <-chan struct{}
71 // Packs is the pack-generation limiter a command that runs git to
72 // produce an archive takes a slot from; nil is no limit.
73 Packs *packlimit.Limiter
74 // Busy is set when a limiter turned the command away, so the API
75 // can answer 503 with Retry-After rather than a failure.
76 Busy bool
77}
78
79// SourceWeb is Ctx.Source for a request from a browser session. Its
80// User.SignedInAt is when that session signed in.
81const SourceWeb = "web"
82
83// SourceMail is Ctx.Source for a comment posted by replying to
84// notification mail (#295).
85const SourceMail = "mail"
86
87// ReauthWindow is how long after signing in a browser session may run a
88// NeedsRecentSignIn command. A session lasts days and its cookie is a
89// bearer credential; what it creates or grants must come from a recent
90// sign-in (#297).
91const ReauthWindow = 15 * time.Minute
92
93// ReauthRefusal is what a web session signed in longer ago than
94// ReauthWindow gets; the web shows a sign-in link beside it.
95var ReauthRefusal = fmt.Sprintf("this action from the web needs a sign-in from the last %d minutes; sign in again, then submit the form again",
96 int(ReauthWindow/time.Minute))
97
98// staleSignIn reports whether a web session that signed in at at is too
99// old, at now, to run a NeedsRecentSignIn command. A zero at is stale.
100func staleSignIn(at, now time.Time) bool {
101 return now.Sub(at) > ReauthWindow
102}
103
104// usage reports a bad invocation with the command's registered usage,
105// the one source of it.
106func (c *Ctx) usage() int {
107 return c.fail(protocol.ExitUsage, "usage: %s", c.cmdUsage())
108}
109
110// usageWith reports a specific problem with the arguments, then the
111// registered usage, so a person always sees the shape that was expected.
112func (c *Ctx) usageWith(msg string) int {
113 return c.fail(protocol.ExitUsage, "%s\nusage: %s", msg, c.cmdUsage())
114}
115
116// Flag is one flag in a command's help.
117type Flag struct {
118 Name string `json:"name"` // "--state"
119 Arg string `json:"arg,omitempty"` // "open|closed|all"; empty for a switch
120 Desc string `json:"desc,omitempty"` // what it does, lower case, no full stop
121 Default string `json:"default,omitempty"` // empty for none
122}
123
124type Command struct {
125 Path []string // e.g. ["keys", "add"]
126 // Summary is one line of prose: what the command does, no argument
127 // syntax. Usage is the argument syntax, opening with the command path.
128 // help renders them separately, so neither may carry the other's job.
129 Summary string
130 Usage string
131 Flags []Flag
132 Examples []string // full argv after the program, repository named
133 ReadsStdin bool
134 ReadOnly bool // safe for read-scoped API tokens
135 // MintsCredential marks a command that creates a credential or a way
136 // to obtain one: tokens, keys, login links, invites, accounts,
137 // verified addresses. An expiring credential may not run it.
138 MintsCredential bool
139 // NeedsRecentSignIn marks a command a browser session may run only
140 // within ReauthWindow of signing in: every MintsCredential command,
141 // and those that give an account lasting access or open a standing
142 // channel out of the instance.
143 NeedsRecentSignIn bool
144 Run func(c *Ctx, args []string) int
145}
146
147var registry []Command
148
149func register(cmd Command) { registry = append(registry, cmd) }
150
151// Commands returns the registry, for the bare-ssh reachability test.
152func Commands() []Command { return registry }
153
154// Lookup resolves argv to a command by longest path match, returning the
155// command and the remaining arguments.
156func Lookup(argv []string) (Command, []string, bool) {
157 best := -1
158 var found Command
159 for _, cmd := range registry {
160 if len(cmd.Path) <= len(argv) && slices.Equal(cmd.Path, argv[:len(cmd.Path)]) && len(cmd.Path) > best {
161 best = len(cmd.Path)
162 found = cmd
163 }
164 }
165 if best < 0 {
166 return Command{}, nil, false
167 }
168 return found, argv[best:], true
169}
170
171// Dispatch runs argv for an authenticated session. The dispatcher — not the
172// handlers — enforces key scope: control commands require a full-scope key.
173func Dispatch(c *Ctx, argv []string) int {
174 if len(argv) == 0 {
175 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
176 }
177 // A leading --term=<v> selects terminal output for this session, the
178 // same as GITBAY_TERM; a leading --path=<v> is the CLI's own path for
179 // the command (Ctx.CLIPath). Both come off before Lookup, in either
180 // order: Lookup matches argv against a command's Path, and either in
181 // front would never match one. Over HTTP both are dropped unread: the
182 // web and the API render no terminal and have no CLI path.
183 for len(argv) > 0 {
184 if v, ok := strings.CutPrefix(argv[0], "--term="); ok {
185 if !c.ViaAPI {
186 c.Term = ParseTerm(v)
187 }
188 } else if v, ok := strings.CutPrefix(argv[0], "--path="); ok {
189 if !c.ViaAPI {
190 c.CLIPath = v
191 }
192 } else {
193 break
194 }
195 argv = argv[1:]
196 }
197 if len(argv) == 0 {
198 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
199 }
200 cmd, rest, ok := Lookup(argv)
201 c.Cmd = cmd
202 if !ok {
203 return c.fail(protocol.ExitUsage, "unknown command %q", argv[0])
204 }
205 // Strip the global --json flag wherever it appears, before any
206 // refusal below: a scripted caller needs the envelope most when it is
207 // being told no (#109).
208 args := rest[:0:0]
209 for _, a := range rest {
210 if a == "--json" {
211 c.JSON = true
212 continue
213 }
214 args = append(args, a)
215 }
216 c.Argv = args
217 code := runChecked(c, cmd, args)
218 if !cmd.ReadOnly {
219 switch code {
220 case protocol.ExitOK:
221 // Every successful mutating command lands in the audit log.
222 c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{"argv": auditArgs(args), "source": c.Source})
223 case protocol.ExitDenied, protocol.ExitNotFound:
224 // So does every refused one: probing leaves a trace.
225 AuditRefused(c.Store, c.User.ID, "refused "+joinPath(cmd.Path),
226 map[string]any{"argv": refusalArgs(args), "source": c.Source, "exit": code})
227 }
228 }
229 return code
230}
231
232// runChecked applies the dispatcher's own gates, then runs the command.
233func runChecked(c *Ctx, cmd Command, args []string) int {
234 // A runner-scoped key reaches the runner protocol and nothing else, so
235 // the key a CI host holds cannot administer the instance.
236 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") {
237 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands; use a key added with --scope full", c.Scope)
238 }
239 if c.ReadOnly && !cmd.ReadOnly {
240 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path))
241 }
242 // What an expiring credential creates would outlive it (#257).
243 if cmd.MintsCredential && c.Expires != nil {
244 return c.fail(protocol.ExitDenied,
245 "%s creates a credential, and the one this request came with expires; use a token or key without an expiry", joinPath(cmd.Path))
246 }
247 // The SSH listener refuses a disabled account before it gets here; the
248 // API and the web reach Dispatch directly, so the check lives here too.
249 if c.User.Disabled {
250 if c.User.DeleteAfter != "" {
251 return c.fail(protocol.ExitDenied, "%s", ScheduledRefusal(c.User))
252 }
253 return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it")
254 }
255 if cmd.NeedsRecentSignIn && c.Source == SourceWeb && staleSignIn(c.User.SignedInAt, time.Now()) {
256 return c.fail(protocol.ExitDenied, "%s", ReauthRefusal)
257 }
258 // The admin noun is gated here as well as in each handler, so a new
259 // admin command that forgets requireInstanceAdmin is still refused.
260 if cmd.Path[0] == "admin" && !c.User.IsAdmin {
261 return c.fail(protocol.ExitDenied, "admin commands are for instance admins; ask one")
262 }
263 if c.User.Pending && !pendingAllowed(cmd.Path) {
264 return c.fail(protocol.ExitDenied,
265 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
266 }
267 if code := limitWrites(c, cmd); code >= 0 {
268 return code
269 }
270 if !cmd.ReadsStdin {
271 c.Stdin = emptyReader{}
272 }
273 return cmd.Run(c, args)
274}
275
276// auditArgs is argv with flag values dropped. Secrets never reach argv —
277// they travel on stdin — but prose does: `issue create a/b --title x
278// --body <the whole issue>` used to store the body verbatim, in a table
279// nothing pruned, for a repository that may be private. The identifiers
280// are positional, so keeping those and the flag names says what was done
281// without copying what was written (#122).
282func auditArgs(args []string) []string {
283 out := make([]string, 0, len(args))
284 for i := 0; i < len(args); i++ {
285 a := args[i]
286 if !strings.HasPrefix(a, "--") {
287 out = append(out, a)
288 continue
289 }
290 // A gate refuses before parseFlags runs, so a "--name=value"
291 // token reaches here whole; only the name is kept.
292 name, _, _ := strings.Cut(a, "=")
293 out = append(out, name)
294 // "--" ends flag parsing; everything after it is positional.
295 if a == "--" {
296 out = append(out, args[i+1:]...)
297 break
298 }
299 // A flag's value is the next argument unless that is itself a
300 // flag, which is how a switch is told from one that takes a value
301 // without consulting the command's spec.
302 if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
303 i++
304 }
305 }
306 return out
307}
308
309// refusalArgs is what a refusal row keeps of argv: the flag names and the
310// first positional, which names the target. A gate refuses before the
311// handler checks its arguments, so later positionals may be anything the
312// caller typed, a value meant for stdin included.
313func refusalArgs(args []string) []string {
314 out := []string{}
315 target := false
316 for _, a := range auditArgs(args) {
317 if a == "--" {
318 break
319 }
320 if strings.HasPrefix(a, "--") {
321 out = append(out, a)
322 } else if !target {
323 out = append(out, a)
324 target = true
325 }
326 }
327 return out
328}
329
330// pendingAllowed lists what an unverified self-registered account may do.
331// limitWrites spends one token of the account's write budget, and refuses
332// with the wait when it is empty. Returns -1 when the command may run.
333//
334// Exempt: read-only commands, which cost the instance nothing to serve
335// twice; the runner protocol, which streams a build's log in many small
336// writes and would throttle CI; and the host CLI on the server, which has
337// no account to key on and is already root-equivalent.
338func limitWrites(c *Ctx, cmd Command) int {
339 if cmd.ReadOnly || cmd.Path[0] == "runner" || c.Source == "host" || c.User.ID == 0 {
340 return -1
341 }
342 perMinute := c.Cfg.Limits.WriteRate
343 if perMinute == 0 {
344 perMinute = config.DefaultWriteRate
345 }
346 if perMinute < 0 {
347 return -1
348 }
349 if ok, wait := writes.allow(c.User.ID, perMinute); !ok {
350 return c.fail(protocol.ExitDenied,
351 "too many writes: %d a minute per account; try again in %s",
352 perMinute, wait.Round(time.Second))
353 }
354 return -1
355}
356
357func pendingAllowed(path []string) bool {
358 key := joinPath(path)
359 return key == "email verify" || key == "email add" || key == "whoami" || key == "help"
360}
361
362type emptyReader struct{}
363
364func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
365
366// emitView is emit for a command with a terminal screen: --json and
367// piped output are emit's, and at a terminal build's screen is drawn
368// in place of plain.
369func (c *Ctx) emitView(data any, plain func(w io.Writer), build func() screen) int {
370 if c.Term.Cols == 0 || c.JSON {
371 return c.emit(data, plain)
372 }
373 return c.emit(data, func(w io.Writer) { c.render(w, build()) })
374}
375
376// emit writes data as the command result: a JSON envelope under --json,
377// otherwise via the plain formatter.
378func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
379 // A nil slice would serialize as null; consumers should see [].
380 v := reflect.ValueOf(data)
381 if v.Kind() == reflect.Slice && v.IsNil() {
382 data = reflect.MakeSlice(v.Type(), 0, 0).Interface()
383 }
384 // An empty list prints nothing a script would read; the person at
385 // the terminal hears about it on stderr.
386 if !c.JSON && v.Kind() == reflect.Slice && v.Len() == 0 {
387 fmt.Fprintln(c.Stderr, "nothing to list")
388 return protocol.ExitOK
389 }
390 if c.JSON {
391 enc := json.NewEncoder(c.Stdout)
392 enc.SetEscapeHTML(false)
393 if err := enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Data: data}); err != nil {
394 return protocol.ExitFailure
395 }
396 return protocol.ExitOK
397 }
398 plain(c.Stdout)
399 return protocol.ExitOK
400}
401
402// failErr reports an error from a store call: not-found is not-found,
403// and anything else — the database failing, a duplicate, a state that
404// does not allow the change — is a failure. An error about the caller's
405// own arguments goes through failInput instead; this used to default to
406// usage, which turned every refusal into exit 2 (#211).
407func (c *Ctx) failErr(err error) int {
408 if errors.Is(err, store.ErrNotFound) {
409 return c.fail(protocol.ExitNotFound, "%v", err)
410 }
411 return c.fail(protocol.ExitFailure, "%v", err)
412}
413
414// failInput reports an error about the caller's input — a name that does
415// not validate, a flag value out of range, a body that could not be read
416// — as a usage error, unless the database or I/O failed underneath it.
417// A SQLite I/O error used to be a usage error and an HTTP 400 (#107).
418func (c *Ctx) failInput(err error) int {
419 switch {
420 case errors.Is(err, store.ErrNotFound):
421 return c.fail(protocol.ExitNotFound, "%v", err)
422 case store.IsInternal(err):
423 return c.fail(protocol.ExitFailure, "%v", err)
424 }
425 return c.fail(protocol.ExitUsage, "%v", err)
426}
427
428func (c *Ctx) fail(code int, format string, args ...any) int {
429 msg := fmt.Sprintf(format, args...)
430 if c.JSON {
431 enc := json.NewEncoder(c.Stdout)
432 enc.SetEscapeHTML(false)
433 enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg})
434 } else {
435 if c.Term.Cols > 0 {
436 msg = c.Term.failure(msg)
437 }
438 fmt.Fprintln(c.Stderr, msg)
439 }
440 return code
441}
442
443func joinPath(p []string) string {
444 out := ""
445 for i, s := range p {
446 if i > 0 {
447 out += " "
448 }
449 out += s
450 }
451 return out
452}