// Package control implements the forge control commands executed over SSH. // Every command here is reachable from bare OpenSSH: argv in, JSON or plain // text on stdout, diagnostics on stderr, exit code out. package control import ( "encoding/json" "errors" "fmt" "io" "reflect" "slices" "strings" "time" "gitbay.org/gitbay/internal/config" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/store" ) type Ctx struct { User store.User Scope string // scope of the key that authenticated this session Store *store.Store Cfg config.Config Stdin io.Reader Stdout io.Writer Stderr io.Writer JSON bool // ViaAPI marks requests arriving over HTTP, from the token API or // the web. Every command runs there; nothing is held back for SSH // any more (#234). The flag stays because the rate limiter and the // audit log want to know which door a request came through. ViaAPI bool // ReadOnly is set for read-scoped API tokens. ReadOnly bool // Source identifies the credential behind this session for the audit // log: an SSH key fingerprint, or "api" for token requests. Source string // Cmd is the command being run, set by Dispatch, so a usage error can // print the registered usage rather than a copy of it. Cmd Command // Done, when the surface has one, closes when nobody is reading any // more: the SSH channel closed or the HTTP request ended. A command // that runs until something happens (build log --follow) stops on it. Done <-chan struct{} // Stopping, when the surface has one, closes when the daemon is // restarting. It closes Done too; a command that ends on Done checks // it to say why. Stopping <-chan struct{} } // usage reports a bad invocation with the command's registered usage, // the one source of it. func (c *Ctx) usage() int { return c.fail(protocol.ExitUsage, "usage: %s", c.Cmd.Usage) } // usageWith reports a specific problem with the arguments, then the // registered usage, so a person always sees the shape that was expected. func (c *Ctx) usageWith(msg string) int { return c.fail(protocol.ExitUsage, "%s\nusage: %s", msg, c.Cmd.Usage) } type Command struct { Path []string // e.g. ["keys", "add"] // Summary is one line of prose: what the command does, no argument // syntax. Usage is the argument syntax, opening with the command path. // help renders them separately, so neither may carry the other's job. Summary string Usage string ReadsStdin bool ReadOnly bool // safe for read-scoped API tokens Run func(c *Ctx, args []string) int } var registry []Command func register(cmd Command) { registry = append(registry, cmd) } // Commands returns the registry, for the bare-ssh reachability test. func Commands() []Command { return registry } // Lookup resolves argv to a command by longest path match, returning the // command and the remaining arguments. func Lookup(argv []string) (Command, []string, bool) { best := -1 var found Command for _, cmd := range registry { if len(cmd.Path) <= len(argv) && slices.Equal(cmd.Path, argv[:len(cmd.Path)]) && len(cmd.Path) > best { best = len(cmd.Path) found = cmd } } if best < 0 { return Command{}, nil, false } return found, argv[best:], true } // Dispatch runs argv for an authenticated session. The dispatcher — not the // handlers — enforces key scope: control commands require a full-scope key. func Dispatch(c *Ctx, argv []string) int { if len(argv) == 0 { return c.fail(protocol.ExitUsage, "no command given; try: ssh help") } cmd, rest, ok := Lookup(argv) c.Cmd = cmd if !ok { return c.fail(protocol.ExitUsage, "unknown command %q", argv[0]) } // Strip the global --json flag wherever it appears, before any // refusal below: a scripted caller needs the envelope most when it is // being told no (#109). args := rest[:0:0] for _, a := range rest { if a == "--json" { c.JSON = true continue } args = append(args, a) } // A runner-scoped key reaches the runner protocol and nothing else, so // the key a CI host holds cannot administer the instance. if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") { return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands; use a key added with --scope full", c.Scope) } if c.ReadOnly && !cmd.ReadOnly { return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path)) } // The SSH listener refuses a disabled account before it gets here; the // API and the web reach Dispatch directly, so the check lives here too. if c.User.Disabled { return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it") } // The admin noun is gated here as well as in each handler, so a new // admin command that forgets requireInstanceAdmin is still refused. if cmd.Path[0] == "admin" && !c.User.IsAdmin { return c.fail(protocol.ExitDenied, "admin commands are for instance admins; ask one") } if c.User.Pending && !pendingAllowed(cmd.Path) { return c.fail(protocol.ExitDenied, "your account is not active yet: verify your email first (email verify , or ask for the mail again with email add)") } if code := limitWrites(c, cmd); code >= 0 { return code } if !cmd.ReadsStdin { c.Stdin = emptyReader{} } code := cmd.Run(c, args) // Every successful mutating command lands in the audit log. if code == protocol.ExitOK && !cmd.ReadOnly { c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{ "argv": auditArgs(args), "source": c.Source, }) } return code } // auditArgs is argv with flag values dropped. Secrets never reach argv — // they travel on stdin — but prose does: `issue create a/b --title x // --body ` used to store the body verbatim, in a table // nothing pruned, for a repository that may be private. The identifiers // are positional, so keeping those and the flag names says what was done // without copying what was written (#122). func auditArgs(args []string) []string { out := make([]string, 0, len(args)) for i := 0; i < len(args); i++ { a := args[i] if !strings.HasPrefix(a, "--") { out = append(out, a) continue } out = append(out, a) // "--" ends flag parsing; everything after it is positional. if a == "--" { out = append(out, args[i+1:]...) break } // A flag's value is the next argument unless that is itself a // flag, which is how a switch is told from one that takes a value // without consulting the command's spec. if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") { i++ } } return out } // pendingAllowed lists what an unverified self-registered account may do. // limitWrites spends one token of the account's write budget, and refuses // with the wait when it is empty. Returns -1 when the command may run. // // Exempt: read-only commands, which cost the instance nothing to serve // twice; the runner protocol, which streams a build's log in many small // writes and would throttle CI; and the host CLI on the server, which has // no account to key on and is already root-equivalent. func limitWrites(c *Ctx, cmd Command) int { if cmd.ReadOnly || cmd.Path[0] == "runner" || c.Source == "host" || c.User.ID == 0 { return -1 } perMinute := c.Cfg.Limits.WriteRate if perMinute == 0 { perMinute = config.DefaultWriteRate } if perMinute < 0 { return -1 } if ok, wait := writes.allow(c.User.ID, perMinute); !ok { return c.fail(protocol.ExitDenied, "too many writes: %d a minute per account; try again in %s", perMinute, wait.Round(time.Second)) } return -1 } func pendingAllowed(path []string) bool { key := joinPath(path) return key == "email verify" || key == "email add" || key == "whoami" || key == "help" } type emptyReader struct{} func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } // emit writes data as the command result: a JSON envelope under --json, // otherwise via the plain formatter. func (c *Ctx) emit(data any, plain func(w io.Writer)) int { // A nil slice would serialize as null; consumers should see []. v := reflect.ValueOf(data) if v.Kind() == reflect.Slice && v.IsNil() { data = reflect.MakeSlice(v.Type(), 0, 0).Interface() } // An empty list prints nothing a script would read; the person at // the terminal hears about it on stderr. if !c.JSON && v.Kind() == reflect.Slice && v.Len() == 0 { fmt.Fprintln(c.Stderr, "nothing to list") return protocol.ExitOK } if c.JSON { enc := json.NewEncoder(c.Stdout) enc.SetEscapeHTML(false) if err := enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Data: data}); err != nil { return protocol.ExitFailure } return protocol.ExitOK } plain(c.Stdout) return protocol.ExitOK } // failErr reports an error from a store call: not-found is not-found, // and anything else — the database failing, a duplicate, a state that // does not allow the change — is a failure. An error about the caller's // own arguments goes through failInput instead; this used to default to // usage, which turned every refusal into exit 2 (#211). func (c *Ctx) failErr(err error) int { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "%v", err) } return c.fail(protocol.ExitFailure, "%v", err) } // failInput reports an error about the caller's input — a name that does // not validate, a flag value out of range, a body that could not be read // — as a usage error, unless the database or I/O failed underneath it. // A SQLite I/O error used to be a usage error and an HTTP 400 (#107). func (c *Ctx) failInput(err error) int { switch { case errors.Is(err, store.ErrNotFound): return c.fail(protocol.ExitNotFound, "%v", err) case store.IsInternal(err): return c.fail(protocol.ExitFailure, "%v", err) } return c.fail(protocol.ExitUsage, "%v", err) } func (c *Ctx) fail(code int, format string, args ...any) int { msg := fmt.Sprintf(format, args...) if c.JSON { enc := json.NewEncoder(c.Stdout) enc.SetEscapeHTML(false) enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg}) } else { fmt.Fprintln(c.Stderr, msg) } return code } func init() { register(Command{ Path: []string{"help"}, Summary: "list available commands", Usage: "help [...]", ReadOnly: true, Run: runHelp, }) } // helpEntry is one row of the registry as help reports it. type helpEntry struct { Path string `json:"path"` Summary string `json:"summary"` Usage string `json:"usage"` } // runHelp lists the registry, sorted, so a noun's commands sit together. // A prefix narrows the listing and adds each command's argument syntax — // the only place flags are written down. The unfiltered listing stays one // line per command. func runHelp(c *Ctx, args []string) int { prefix := joinPath(args) var matched []helpEntry for _, cmd := range registry { p := joinPath(cmd.Path) if prefix != "" && p != prefix && !strings.HasPrefix(p, prefix+" ") { continue } matched = append(matched, helpEntry{Path: p, Summary: cmd.Summary, Usage: cmd.Usage}) } if len(matched) == 0 { return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix) } slices.SortFunc(matched, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) }) return c.emit(matched, func(w io.Writer) { for _, e := range matched { fmt.Fprintf(w, "%-24s %s\n", e.Path, e.Summary) if prefix != "" { fmt.Fprintf(w, " %s\n", e.Usage) } } }) } func joinPath(p []string) string { out := "" for i, s := range p { if i > 0 { out += " " } out += s } return out }