internal/control/control.go
219 lines · 6574 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 if c.Scope != "full" {
87 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope)
88 }
89 if c.ViaAPI && cmd.SSHOnly {
90 return c.fail(protocol.ExitDenied, "%s is only available over SSH", joinPath(cmd.Path))
91 }
92 if c.ReadOnly && !cmd.ReadOnly {
93 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state", joinPath(cmd.Path))
94 }
95 if c.User.Pending && !pendingAllowed(cmd.Path) {
96 return c.fail(protocol.ExitDenied,
97 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
98 }
99 // Strip the global --json flag wherever it appears.
100 args := rest[:0:0]
101 for _, a := range rest {
102 if a == "--json" {
103 c.JSON = true
104 continue
105 }
106 args = append(args, a)
107 }
108 if !cmd.ReadsStdin {
109 c.Stdin = emptyReader{}
110 }
111 code := cmd.Run(c, args)
112 // Every successful mutating command lands in the audit log. Argv is
113 // safe to record by construction: secrets travel on stdin, never as
114 // arguments.
115 if code == protocol.ExitOK && !cmd.ReadOnly {
116 c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{
117 "argv": args,
118 "source": c.Source,
119 })
120 }
121 return code
122}
123
124// pendingAllowed lists what an unverified self-registered account may do.
125func pendingAllowed(path []string) bool {
126 key := joinPath(path)
127 return key == "email verify" || key == "email add" || key == "whoami" || key == "help"
128}
129
130type emptyReader struct{}
131
132func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
133
134// emit writes data as the command result: a JSON envelope under --json,
135// otherwise via the plain formatter.
136func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
137 // A nil slice would serialize as null; consumers should see [].
138 if v := reflect.ValueOf(data); v.Kind() == reflect.Slice && v.IsNil() {
139 data = reflect.MakeSlice(v.Type(), 0, 0).Interface()
140 }
141 if c.JSON {
142 enc := json.NewEncoder(c.Stdout)
143 enc.SetEscapeHTML(false)
144 if err := enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Data: data}); err != nil {
145 return protocol.ExitFailure
146 }
147 return protocol.ExitOK
148 }
149 plain(c.Stdout)
150 return protocol.ExitOK
151}
152
153func (c *Ctx) fail(code int, format string, args ...any) int {
154 msg := fmt.Sprintf(format, args...)
155 if c.JSON {
156 enc := json.NewEncoder(c.Stdout)
157 enc.SetEscapeHTML(false)
158 enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg})
159 } else {
160 fmt.Fprintln(c.Stderr, msg)
161 }
162 return code
163}
164
165func init() {
166 register(Command{
167 Path: []string{"help"},
168 Summary: "list available commands",
169 Usage: "help [<prefix>...]",
170 ReadOnly: true,
171 Run: runHelp,
172 })
173}
174
175// helpEntry is one row of the registry as help reports it.
176type helpEntry struct {
177 Path string `json:"path"`
178 Summary string `json:"summary"`
179 Usage string `json:"usage"`
180}
181
182// runHelp lists the registry, sorted, so a noun's commands sit together.
183// A prefix narrows the listing and adds each command's argument syntax —
184// the only place flags are written down. The unfiltered listing stays one
185// line per command.
186func runHelp(c *Ctx, args []string) int {
187 prefix := joinPath(args)
188 var matched []helpEntry
189 for _, cmd := range registry {
190 p := joinPath(cmd.Path)
191 if prefix != "" && p != prefix && !strings.HasPrefix(p, prefix+" ") {
192 continue
193 }
194 matched = append(matched, helpEntry{Path: p, Summary: cmd.Summary, Usage: cmd.Usage})
195 }
196 if len(matched) == 0 {
197 return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
198 }
199 slices.SortFunc(matched, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) })
200 return c.emit(matched, func(w io.Writer) {
201 for _, e := range matched {
202 fmt.Fprintf(w, "%-24s %s\n", e.Path, e.Summary)
203 if prefix != "" {
204 fmt.Fprintf(w, " %s\n", e.Usage)
205 }
206 }
207 })
208}
209
210func joinPath(p []string) string {
211 out := ""
212 for i, s := range p {
213 if i > 0 {
214 out += " "
215 }
216 out += s
217 }
218 return out
219}