internal/control/control.go
183 lines · 5303 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
13 "gitbay.org/gitbay/internal/config"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/store"
16)
17
18type Ctx struct {
19 User store.User
20 Scope string // scope of the key that authenticated this session
21 Store *store.Store
22 Cfg config.Config
23 Stdin io.Reader
24 Stdout io.Writer
25 Stderr io.Writer
26 JSON bool
27 // ViaAPI marks requests arriving over the HTTP token API. Some
28 // commands (token management) are SSH-only: an API token must never
29 // mint further credentials.
30 ViaAPI bool
31 // ReadOnly is set for read-scoped API tokens.
32 ReadOnly bool
33 // Source identifies the credential behind this session for the audit
34 // log: an SSH key fingerprint, or "api" for token requests.
35 Source string
36}
37
38type Command struct {
39 Path []string // e.g. ["keys", "add"]
40 Summary string
41 ReadsStdin bool
42 ReadOnly bool // safe for read-scoped API tokens
43 SSHOnly bool // refused over the HTTP API (credential minting)
44 Run func(c *Ctx, args []string) int
45}
46
47var registry []Command
48
49func register(cmd Command) { registry = append(registry, cmd) }
50
51// Commands returns the registry, for the bare-ssh reachability test.
52func Commands() []Command { return registry }
53
54// Lookup resolves argv to a command by longest path match, returning the
55// command and the remaining arguments.
56func Lookup(argv []string) (Command, []string, bool) {
57 best := -1
58 var found Command
59 for _, cmd := range registry {
60 if len(cmd.Path) <= len(argv) && slices.Equal(cmd.Path, argv[:len(cmd.Path)]) && len(cmd.Path) > best {
61 best = len(cmd.Path)
62 found = cmd
63 }
64 }
65 if best < 0 {
66 return Command{}, nil, false
67 }
68 return found, argv[best:], true
69}
70
71// Dispatch runs argv for an authenticated session. The dispatcher — not the
72// handlers — enforces key scope: control commands require a full-scope key.
73func Dispatch(c *Ctx, argv []string) int {
74 if len(argv) == 0 {
75 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
76 }
77 cmd, rest, ok := Lookup(argv)
78 if !ok {
79 return c.fail(protocol.ExitUsage, "unknown command %q", argv[0])
80 }
81 if c.Scope != "full" {
82 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope)
83 }
84 if c.ViaAPI && cmd.SSHOnly {
85 return c.fail(protocol.ExitDenied, "%s is only available over SSH", joinPath(cmd.Path))
86 }
87 if c.ReadOnly && !cmd.ReadOnly {
88 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state", joinPath(cmd.Path))
89 }
90 if c.User.Pending && !pendingAllowed(cmd.Path) {
91 return c.fail(protocol.ExitDenied,
92 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
93 }
94 // Strip the global --json flag wherever it appears.
95 args := rest[:0:0]
96 for _, a := range rest {
97 if a == "--json" {
98 c.JSON = true
99 continue
100 }
101 args = append(args, a)
102 }
103 if !cmd.ReadsStdin {
104 c.Stdin = emptyReader{}
105 }
106 code := cmd.Run(c, args)
107 // Every successful mutating command lands in the audit log. Argv is
108 // safe to record by construction: secrets travel on stdin, never as
109 // arguments.
110 if code == protocol.ExitOK && !cmd.ReadOnly {
111 c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{
112 "argv": args,
113 "source": c.Source,
114 })
115 }
116 return code
117}
118
119// pendingAllowed lists what an unverified self-registered account may do.
120func pendingAllowed(path []string) bool {
121 key := joinPath(path)
122 return key == "email verify" || key == "email add" || key == "whoami" || key == "help"
123}
124
125type emptyReader struct{}
126
127func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
128
129// emit writes data as the command result: a JSON envelope under --json,
130// otherwise via the plain formatter.
131func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
132 // A nil slice would serialize as null; consumers should see [].
133 if v := reflect.ValueOf(data); v.Kind() == reflect.Slice && v.IsNil() {
134 data = reflect.MakeSlice(v.Type(), 0, 0).Interface()
135 }
136 if c.JSON {
137 enc := json.NewEncoder(c.Stdout)
138 enc.SetEscapeHTML(false)
139 if err := enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Data: data}); err != nil {
140 return protocol.ExitFailure
141 }
142 return protocol.ExitOK
143 }
144 plain(c.Stdout)
145 return protocol.ExitOK
146}
147
148func (c *Ctx) fail(code int, format string, args ...any) int {
149 msg := fmt.Sprintf(format, args...)
150 if c.JSON {
151 enc := json.NewEncoder(c.Stdout)
152 enc.SetEscapeHTML(false)
153 enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg})
154 } else {
155 fmt.Fprintln(c.Stderr, msg)
156 }
157 return code
158}
159
160func init() {
161 register(Command{
162 Path: []string{"help"},
163 Summary: "list available commands",
164 ReadOnly: true,
165 Run: func(c *Ctx, args []string) int {
166 for _, cmd := range registry {
167 fmt.Fprintf(c.Stdout, "%-24s %s\n", joinPath(cmd.Path), cmd.Summary)
168 }
169 return protocol.ExitOK
170 },
171 })
172}
173
174func joinPath(p []string) string {
175 out := ""
176 for i, s := range p {
177 if i > 0 {
178 out += " "
179 }
180 out += s
181 }
182 return out
183}