// 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/packlimit" "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 // Term is the client's terminal, from GITBAY_TERM. The zero value // is plain output. Term Term // CLIPath is the path the gitbay CLI resolved this call to, from a // leading --path=, when it differs from the registered path being // dispatched (auth keys remove for keys remove). Usage and help // print it in place of the registered path (#267). Empty for stock // ssh, the web and the API. CLIPath string // 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 // TokenID is the API token behind this request, 0 for none. A // credential the request creates records it. TokenID int64 // Expires is when the credential behind this request lapses; nil // when it does not. Dispatch refuses MintsCredential commands when // it is set. Expires *time.Time // 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 // Argv is the command's arguments after its path, global flags // removed, so output can print a command to run next. Argv []string // 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{} // Packs is the pack-generation limiter a command that runs git to // produce an archive takes a slot from; nil is no limit. Packs *packlimit.Limiter // Busy is set when a limiter turned the command away, so the API // can answer 503 with Retry-After rather than a failure. Busy bool } // SourceWeb is Ctx.Source for a request from a browser session. Its // User.SignedInAt is when that session signed in. const SourceWeb = "web" // SourceMail is Ctx.Source for a comment posted by replying to // notification mail (#295). const SourceMail = "mail" // ReauthWindow is how long after signing in a browser session may run a // NeedsRecentSignIn command. A session lasts days and its cookie is a // bearer credential; what it creates or grants must come from a recent // sign-in (#297). const ReauthWindow = 15 * time.Minute // ReauthRefusal is what a web session signed in longer ago than // ReauthWindow gets; the web shows a sign-in link beside it. var ReauthRefusal = fmt.Sprintf("this action from the web needs a sign-in from the last %d minutes; sign in again, then submit the form again", int(ReauthWindow/time.Minute)) // staleSignIn reports whether a web session that signed in at at is too // old, at now, to run a NeedsRecentSignIn command. A zero at is stale. func staleSignIn(at, now time.Time) bool { return now.Sub(at) > ReauthWindow } // 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.cmdUsage()) } // 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.cmdUsage()) } // Flag is one flag in a command's help. type Flag struct { Name string `json:"name"` // "--state" Arg string `json:"arg,omitempty"` // "open|closed|all"; empty for a switch Desc string `json:"desc,omitempty"` // what it does, lower case, no full stop Default string `json:"default,omitempty"` // empty for none } 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 Flags []Flag Examples []string // full argv after the program, repository named ReadsStdin bool ReadOnly bool // safe for read-scoped API tokens // MintsCredential marks a command that creates a credential or a way // to obtain one: tokens, keys, login links, invites, accounts, // verified addresses. An expiring credential may not run it. MintsCredential bool // NeedsRecentSignIn marks a command a browser session may run only // within ReauthWindow of signing in: every MintsCredential command, // and those that give an account lasting access or open a standing // channel out of the instance. NeedsRecentSignIn bool 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") } // A leading --term= selects terminal output for this session, the // same as GITBAY_TERM; a leading --path= is the CLI's own path for // the command (Ctx.CLIPath). Both come off before Lookup, in either // order: Lookup matches argv against a command's Path, and either in // front would never match one. Over HTTP both are dropped unread: the // web and the API render no terminal and have no CLI path. for len(argv) > 0 { if v, ok := strings.CutPrefix(argv[0], "--term="); ok { if !c.ViaAPI { c.Term = ParseTerm(v) } } else if v, ok := strings.CutPrefix(argv[0], "--path="); ok { if !c.ViaAPI { c.CLIPath = v } } else { break } argv = argv[1:] } 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) } c.Argv = args code := runChecked(c, cmd, args) if !cmd.ReadOnly { switch code { case protocol.ExitOK: // Every successful mutating command lands in the audit log. c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{"argv": auditArgs(args), "source": c.Source}) case protocol.ExitDenied, protocol.ExitNotFound: // So does every refused one: probing leaves a trace. AuditRefused(c.Store, c.User.ID, "refused "+joinPath(cmd.Path), map[string]any{"argv": refusalArgs(args), "source": c.Source, "exit": code}) } } return code } // runChecked applies the dispatcher's own gates, then runs the command. func runChecked(c *Ctx, cmd Command, args []string) int { // 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)) } // What an expiring credential creates would outlive it (#257). if cmd.MintsCredential && c.Expires != nil { return c.fail(protocol.ExitDenied, "%s creates a credential, and the one this request came with expires; use a token or key without an expiry", 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") } if cmd.NeedsRecentSignIn && c.Source == SourceWeb && staleSignIn(c.User.SignedInAt, time.Now()) { return c.fail(protocol.ExitDenied, "%s", ReauthRefusal) } // 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{} } return cmd.Run(c, args) } // 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 } // A gate refuses before parseFlags runs, so a "--name=value" // token reaches here whole; only the name is kept. name, _, _ := strings.Cut(a, "=") out = append(out, name) // "--" 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 } // refusalArgs is what a refusal row keeps of argv: the flag names and the // first positional, which names the target. A gate refuses before the // handler checks its arguments, so later positionals may be anything the // caller typed, a value meant for stdin included. func refusalArgs(args []string) []string { out := []string{} target := false for _, a := range auditArgs(args) { if a == "--" { break } if strings.HasPrefix(a, "--") { out = append(out, a) } else if !target { out = append(out, a) target = true } } 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 joinPath(p []string) string { out := "" for i, s := range p { if i > 0 { out += " " } out += s } return out }