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