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