krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: 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}