internal/control/control.go
250 lines · 8050 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. Argv is
128 // safe to record by construction: secrets travel on stdin, never as
129 // arguments.
130 if code == protocol.ExitOK && !cmd.ReadOnly {
131 c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{
132 "argv": args,
133 "source": c.Source,
134 })
135 }
136 return code
137}
138
139// pendingAllowed lists what an unverified self-registered account may do.
140func pendingAllowed(path []string) bool {
141 key := joinPath(path)
142 return key == "email verify" || key == "email add" || key == "whoami" || key == "help"
143}
144
145type emptyReader struct{}
146
147func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
148
149// emit writes data as the command result: a JSON envelope under --json,
150// otherwise via the plain formatter.
151func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
152 // A nil slice would serialize as null; consumers should see [].
153 if v := reflect.ValueOf(data); v.Kind() == reflect.Slice && v.IsNil() {
154 data = reflect.MakeSlice(v.Type(), 0, 0).Interface()
155 }
156 if c.JSON {
157 enc := json.NewEncoder(c.Stdout)
158 enc.SetEscapeHTML(false)
159 if err := enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Data: data}); err != nil {
160 return protocol.ExitFailure
161 }
162 return protocol.ExitOK
163 }
164 plain(c.Stdout)
165 return protocol.ExitOK
166}
167
168// failErr reports an error from a store or helper call with the exit code
169// its kind deserves: not-found is not-found, the database or I/O failing
170// is a failure, and anything else is the caller's mistake, which is what
171// most such errors are (a name that does not validate, a state that does
172// not allow the change). A SQLite I/O error used to be a usage error and
173// an HTTP 400 (#107).
174func (c *Ctx) failErr(err error) int {
175 switch {
176 case errors.Is(err, store.ErrNotFound):
177 return c.fail(protocol.ExitNotFound, "%v", err)
178 case store.IsInternal(err):
179 return c.fail(protocol.ExitFailure, "%v", err)
180 }
181 return c.fail(protocol.ExitUsage, "%v", err)
182}
183
184func (c *Ctx) fail(code int, format string, args ...any) int {
185 msg := fmt.Sprintf(format, args...)
186 if c.JSON {
187 enc := json.NewEncoder(c.Stdout)
188 enc.SetEscapeHTML(false)
189 enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg})
190 } else {
191 fmt.Fprintln(c.Stderr, msg)
192 }
193 return code
194}
195
196func init() {
197 register(Command{
198 Path: []string{"help"},
199 Summary: "list available commands",
200 Usage: "help [<prefix>...]",
201 ReadOnly: true,
202 Run: runHelp,
203 })
204}
205
206// helpEntry is one row of the registry as help reports it.
207type helpEntry struct {
208 Path string `json:"path"`
209 Summary string `json:"summary"`
210 Usage string `json:"usage"`
211}
212
213// runHelp lists the registry, sorted, so a noun's commands sit together.
214// A prefix narrows the listing and adds each command's argument syntax —
215// the only place flags are written down. The unfiltered listing stays one
216// line per command.
217func runHelp(c *Ctx, args []string) int {
218 prefix := joinPath(args)
219 var matched []helpEntry
220 for _, cmd := range registry {
221 p := joinPath(cmd.Path)
222 if prefix != "" && p != prefix && !strings.HasPrefix(p, prefix+" ") {
223 continue
224 }
225 matched = append(matched, helpEntry{Path: p, Summary: cmd.Summary, Usage: cmd.Usage})
226 }
227 if len(matched) == 0 {
228 return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
229 }
230 slices.SortFunc(matched, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) })
231 return c.emit(matched, func(w io.Writer) {
232 for _, e := range matched {
233 fmt.Fprintf(w, "%-24s %s\n", e.Path, e.Summary)
234 if prefix != "" {
235 fmt.Fprintf(w, " %s\n", e.Usage)
236 }
237 }
238 })
239}
240
241func joinPath(p []string) string {
242 out := ""
243 for i, s := range p {
244 if i > 0 {
245 out += " "
246 }
247 out += s
248 }
249 return out
250}