internal/control/control.go

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