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