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