internal/control/control.go

1ed9fb9399b21da8e6cf45be8389792aac82d5bc
gitbay/internal/control/control.go history · blame · raw

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