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