internal/control/control.go
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}