internal/control/control.go

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

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