internal/control/control.go

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