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