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}