internal/control/control.go

439 lines · 16009 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		return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it")
251	}
252	if cmd.NeedsRecentSignIn && c.Source == SourceWeb && staleSignIn(c.User.SignedInAt, time.Now()) {
253		return c.fail(protocol.ExitDenied, "%s", ReauthRefusal)
254	}
255	// The admin noun is gated here as well as in each handler, so a new
256	// admin command that forgets requireInstanceAdmin is still refused.
257	if cmd.Path[0] == "admin" && !c.User.IsAdmin {
258		return c.fail(protocol.ExitDenied, "admin commands are for instance admins; ask one")
259	}
260	if c.User.Pending && !pendingAllowed(cmd.Path) {
261		return c.fail(protocol.ExitDenied,
262			"your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
263	}
264	if code := limitWrites(c, cmd); code >= 0 {
265		return code
266	}
267	if !cmd.ReadsStdin {
268		c.Stdin = emptyReader{}
269	}
270	return cmd.Run(c, args)
271}
272
273// auditArgs is argv with flag values dropped. Secrets never reach argv —
274// they travel on stdin — but prose does: `issue create a/b --title x
275// --body <the whole issue>` used to store the body verbatim, in a table
276// nothing pruned, for a repository that may be private. The identifiers
277// are positional, so keeping those and the flag names says what was done
278// without copying what was written (#122).
279func auditArgs(args []string) []string {
280	out := make([]string, 0, len(args))
281	for i := 0; i < len(args); i++ {
282		a := args[i]
283		if !strings.HasPrefix(a, "--") {
284			out = append(out, a)
285			continue
286		}
287		// A gate refuses before parseFlags runs, so a "--name=value"
288		// token reaches here whole; only the name is kept.
289		name, _, _ := strings.Cut(a, "=")
290		out = append(out, name)
291		// "--" ends flag parsing; everything after it is positional.
292		if a == "--" {
293			out = append(out, args[i+1:]...)
294			break
295		}
296		// A flag's value is the next argument unless that is itself a
297		// flag, which is how a switch is told from one that takes a value
298		// without consulting the command's spec.
299		if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
300			i++
301		}
302	}
303	return out
304}
305
306// refusalArgs is what a refusal row keeps of argv: the flag names and the
307// first positional, which names the target. A gate refuses before the
308// handler checks its arguments, so later positionals may be anything the
309// caller typed, a value meant for stdin included.
310func refusalArgs(args []string) []string {
311	out := []string{}
312	target := false
313	for _, a := range auditArgs(args) {
314		if a == "--" {
315			break
316		}
317		if strings.HasPrefix(a, "--") {
318			out = append(out, a)
319		} else if !target {
320			out = append(out, a)
321			target = true
322		}
323	}
324	return out
325}
326
327// pendingAllowed lists what an unverified self-registered account may do.
328// limitWrites spends one token of the account's write budget, and refuses
329// with the wait when it is empty. Returns -1 when the command may run.
330//
331// Exempt: read-only commands, which cost the instance nothing to serve
332// twice; the runner protocol, which streams a build's log in many small
333// writes and would throttle CI; and the host CLI on the server, which has
334// no account to key on and is already root-equivalent.
335func limitWrites(c *Ctx, cmd Command) int {
336	if cmd.ReadOnly || cmd.Path[0] == "runner" || c.Source == "host" || c.User.ID == 0 {
337		return -1
338	}
339	perMinute := c.Cfg.Limits.WriteRate
340	if perMinute == 0 {
341		perMinute = config.DefaultWriteRate
342	}
343	if perMinute < 0 {
344		return -1
345	}
346	if ok, wait := writes.allow(c.User.ID, perMinute); !ok {
347		return c.fail(protocol.ExitDenied,
348			"too many writes: %d a minute per account; try again in %s",
349			perMinute, wait.Round(time.Second))
350	}
351	return -1
352}
353
354func pendingAllowed(path []string) bool {
355	key := joinPath(path)
356	return key == "email verify" || key == "email add" || key == "whoami" || key == "help"
357}
358
359type emptyReader struct{}
360
361func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
362
363// emit writes data as the command result: a JSON envelope under --json,
364// otherwise via the plain formatter.
365func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
366	// A nil slice would serialize as null; consumers should see [].
367	v := reflect.ValueOf(data)
368	if v.Kind() == reflect.Slice && v.IsNil() {
369		data = reflect.MakeSlice(v.Type(), 0, 0).Interface()
370	}
371	// An empty list prints nothing a script would read; the person at
372	// the terminal hears about it on stderr.
373	if !c.JSON && v.Kind() == reflect.Slice && v.Len() == 0 {
374		fmt.Fprintln(c.Stderr, "nothing to list")
375		return protocol.ExitOK
376	}
377	if c.JSON {
378		enc := json.NewEncoder(c.Stdout)
379		enc.SetEscapeHTML(false)
380		if err := enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Data: data}); err != nil {
381			return protocol.ExitFailure
382		}
383		return protocol.ExitOK
384	}
385	plain(c.Stdout)
386	return protocol.ExitOK
387}
388
389// failErr reports an error from a store call: not-found is not-found,
390// and anything else — the database failing, a duplicate, a state that
391// does not allow the change — is a failure. An error about the caller's
392// own arguments goes through failInput instead; this used to default to
393// usage, which turned every refusal into exit 2 (#211).
394func (c *Ctx) failErr(err error) int {
395	if errors.Is(err, store.ErrNotFound) {
396		return c.fail(protocol.ExitNotFound, "%v", err)
397	}
398	return c.fail(protocol.ExitFailure, "%v", err)
399}
400
401// failInput reports an error about the caller's input — a name that does
402// not validate, a flag value out of range, a body that could not be read
403// — as a usage error, unless the database or I/O failed underneath it.
404// A SQLite I/O error used to be a usage error and an HTTP 400 (#107).
405func (c *Ctx) failInput(err error) int {
406	switch {
407	case errors.Is(err, store.ErrNotFound):
408		return c.fail(protocol.ExitNotFound, "%v", err)
409	case store.IsInternal(err):
410		return c.fail(protocol.ExitFailure, "%v", err)
411	}
412	return c.fail(protocol.ExitUsage, "%v", err)
413}
414
415func (c *Ctx) fail(code int, format string, args ...any) int {
416	msg := fmt.Sprintf(format, args...)
417	if c.JSON {
418		enc := json.NewEncoder(c.Stdout)
419		enc.SetEscapeHTML(false)
420		enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg})
421	} else {
422		if c.Term.Cols > 0 {
423			msg = c.Term.failure(msg)
424		}
425		fmt.Fprintln(c.Stderr, msg)
426	}
427	return code
428}
429
430func joinPath(p []string) string {
431	out := ""
432	for i, s := range p {
433		if i > 0 {
434			out += " "
435		}
436		out += s
437	}
438	return out
439}