internal/control/control.go

d8bcf1584ceb0dae5906070084c9b7b85174cfa2
gitbay/internal/control/control.go history · blame · raw

145 lines · 3759 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	"github.com/krazywarez/forge/internal/config"
 14	"github.com/krazywarez/forge/internal/protocol"
 15	"github.com/krazywarez/forge/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	// Strip the global --json flag wherever it appears.
 74	args := rest[:0:0]
 75	for _, a := range rest {
 76		if a == "--json" {
 77			c.JSON = true
 78			continue
 79		}
 80		args = append(args, a)
 81	}
 82	if !cmd.ReadsStdin {
 83		c.Stdin = emptyReader{}
 84	}
 85	return cmd.Run(c, args)
 86}
 87
 88type emptyReader struct{}
 89
 90func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
 91
 92// emit writes data as the command result: a JSON envelope under --json,
 93// otherwise via the plain formatter.
 94func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
 95	// A nil slice would serialize as null; consumers should see [].
 96	if v := reflect.ValueOf(data); v.Kind() == reflect.Slice && v.IsNil() {
 97		data = reflect.MakeSlice(v.Type(), 0, 0).Interface()
 98	}
 99	if c.JSON {
100		enc := json.NewEncoder(c.Stdout)
101		enc.SetEscapeHTML(false)
102		if err := enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Data: data}); err != nil {
103			return protocol.ExitFailure
104		}
105		return protocol.ExitOK
106	}
107	plain(c.Stdout)
108	return protocol.ExitOK
109}
110
111func (c *Ctx) fail(code int, format string, args ...any) int {
112	msg := fmt.Sprintf(format, args...)
113	if c.JSON {
114		enc := json.NewEncoder(c.Stdout)
115		enc.SetEscapeHTML(false)
116		enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg})
117	} else {
118		fmt.Fprintln(c.Stderr, msg)
119	}
120	return code
121}
122
123func init() {
124	register(Command{
125		Path:    []string{"help"},
126		Summary: "list available commands",
127		Run: func(c *Ctx, args []string) int {
128			for _, cmd := range registry {
129				fmt.Fprintf(c.Stdout, "%-24s %s\n", joinPath(cmd.Path), cmd.Summary)
130			}
131			return protocol.ExitOK
132		},
133	})
134}
135
136func joinPath(p []string) string {
137	out := ""
138	for i, s := range p {
139		if i > 0 {
140			out += " "
141		}
142		out += s
143	}
144	return out
145}