internal/control/control.go

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