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