internal/control/control.go

6821a6f76082b1e10ff899ff51021b11695c4ad6
gitbay/internal/control/control.go history · blame · raw

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