internal/control/control.go

ff759b53942049b2043e132f5482b442a7265b98
gitbay/internal/control/control.go history · blame · raw

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}