internal/control/control.go

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

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