internal/control/control.go

794702c58d07759975699235f4408486305f8008
gitbay/internal/control/control.go history · blame · raw

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}