internal/control/control.go
155 lines · 4176 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}
28
29type Command struct {
30 Path []string // e.g. ["keys", "add"]
31 Summary string
32 ReadsStdin bool
33 Run func(c *Ctx, args []string) int
34}
35
36var registry []Command
37
38func register(cmd Command) { registry = append(registry, cmd) }
39
40// Commands returns the registry, for the bare-ssh reachability test.
41func Commands() []Command { return registry }
42
43// Lookup resolves argv to a command by longest path match, returning the
44// command and the remaining arguments.
45func Lookup(argv []string) (Command, []string, bool) {
46 best := -1
47 var found Command
48 for _, cmd := range registry {
49 if len(cmd.Path) <= len(argv) && slices.Equal(cmd.Path, argv[:len(cmd.Path)]) && len(cmd.Path) > best {
50 best = len(cmd.Path)
51 found = cmd
52 }
53 }
54 if best < 0 {
55 return Command{}, nil, false
56 }
57 return found, argv[best:], true
58}
59
60// Dispatch runs argv for an authenticated session. The dispatcher — not the
61// handlers — enforces key scope: control commands require a full-scope key.
62func Dispatch(c *Ctx, argv []string) int {
63 if len(argv) == 0 {
64 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
65 }
66 cmd, rest, ok := Lookup(argv)
67 if !ok {
68 return c.fail(protocol.ExitUsage, "unknown command %q", argv[0])
69 }
70 if c.Scope != "full" {
71 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope)
72 }
73 if c.User.Pending && !pendingAllowed(cmd.Path) {
74 return c.fail(protocol.ExitDenied,
75 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
76 }
77 // Strip the global --json flag wherever it appears.
78 args := rest[:0:0]
79 for _, a := range rest {
80 if a == "--json" {
81 c.JSON = true
82 continue
83 }
84 args = append(args, a)
85 }
86 if !cmd.ReadsStdin {
87 c.Stdin = emptyReader{}
88 }
89 return cmd.Run(c, args)
90}
91
92// pendingAllowed lists what an unverified self-registered account may do.
93func pendingAllowed(path []string) bool {
94 key := joinPath(path)
95 return key == "email verify" || key == "email add" || key == "whoami" || key == "help"
96}
97
98type emptyReader struct{}
99
100func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
101
102// emit writes data as the command result: a JSON envelope under --json,
103// otherwise via the plain formatter.
104func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
105 // A nil slice would serialize as null; consumers should see [].
106 if v := reflect.ValueOf(data); v.Kind() == reflect.Slice && v.IsNil() {
107 data = reflect.MakeSlice(v.Type(), 0, 0).Interface()
108 }
109 if c.JSON {
110 enc := json.NewEncoder(c.Stdout)
111 enc.SetEscapeHTML(false)
112 if err := enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Data: data}); err != nil {
113 return protocol.ExitFailure
114 }
115 return protocol.ExitOK
116 }
117 plain(c.Stdout)
118 return protocol.ExitOK
119}
120
121func (c *Ctx) fail(code int, format string, args ...any) int {
122 msg := fmt.Sprintf(format, args...)
123 if c.JSON {
124 enc := json.NewEncoder(c.Stdout)
125 enc.SetEscapeHTML(false)
126 enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg})
127 } else {
128 fmt.Fprintln(c.Stderr, msg)
129 }
130 return code
131}
132
133func init() {
134 register(Command{
135 Path: []string{"help"},
136 Summary: "list available commands",
137 Run: func(c *Ctx, args []string) int {
138 for _, cmd := range registry {
139 fmt.Fprintf(c.Stdout, "%-24s %s\n", joinPath(cmd.Path), cmd.Summary)
140 }
141 return protocol.ExitOK
142 },
143 })
144}
145
146func joinPath(p []string) string {
147 out := ""
148 for i, s := range p {
149 if i > 0 {
150 out += " "
151 }
152 out += s
153 }
154 return out
155}