Commit 8ff79f1044

8ff79f104466ce76ae50fb73fd4169750b88deed

parent: ea05aa6c8c

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 00:46 UTC

JSON API: one endpoint fronting the whole control plane

- POST /api/v1/cmd with {argv, stdin}: real argv (no tokenizer), same
  registry, same dispatcher rules as SSH; exit codes map onto HTTP
  statuses; the body is the command's JSON envelope with exit_code
  (raw-output commands like help/mr diff are wrapped as {output})
- opt-in via [api] enabled (default off): an instance that never turns
  it on has no credential-bearing HTTP surface; the route-table test
  covers both states
- bearer tokens (gb_ prefix for secret scanners) minted ONLY over SSH:
  token create/list/revoke are SSHOnly — an API token can never mint
  further credentials; scopes full|read with ReadOnly command markers
  enforced in the dispatcher; optional --ttl (30d / Go durations);
  hashes only in the DB, uniform 401 for missing/invalid/expired
- git transport refused by name on the API (SSH remains the only path)
- CLI gitbay auth token create/list/revoke passthrough
- e2e: mint over SSH, whoami/mutations/reads through the API, status
  mapping, raw-output wrapping, SSH-only refusal, read-scope write
  denial, 1s-ttl expiry, revocation, and 404 when disabled

Layout: unified · split

cmd/gitbay/main.go +6
@@ -172,7 +172,13 @@ func authCmd() *cobra.Command {
172172 return nil
173173 },
174174 }
175 tokens := group("token", "API tokens (minted over SSH, used with the JSON API)",
176 pass("create", "mint a token: --name <n> [--scope full|read] [--ttl 30d]", passOpts{server: []string{"token", "create"}}),
177 pass("list", "list API tokens", passOpts{server: []string{"token", "list"}}),
178 pass("revoke", "revoke a token by name", passOpts{server: []string{"token", "revoke"}}),
179 )
175180 return group("auth", "identity: whoami, SSH and PGP keys",
181 tokens,
176182 pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}),
177183 group("keys", "manage SSH keys",
178184 pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}),
e2e/api_test.go added +163
@@ -0,0 +1,163 @@
1package e2e
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "strings"
10 "testing"
11 "time"
12)
13
14// apiCall posts one command to the JSON API.
15func (i *instance) apiCall(t *testing.T, token string, argv []string, stdin string) (int, map[string]any) {
16 t.Helper()
17 body, _ := json.Marshal(map[string]any{"argv": argv, "stdin": stdin})
18 req, err := http.NewRequest("POST",
19 fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", i.httpPort), bytes.NewReader(body))
20 if err != nil {
21 t.Fatal(err)
22 }
23 if token != "" {
24 req.Header.Set("Authorization", "Bearer "+token)
25 }
26 resp, err := http.DefaultClient.Do(req)
27 if err != nil {
28 t.Fatal(err)
29 }
30 defer resp.Body.Close()
31 raw, _ := io.ReadAll(resp.Body)
32 var out map[string]any
33 if err := json.Unmarshal(raw, &out); err != nil {
34 t.Fatalf("API response not JSON (%d): %s", resp.StatusCode, raw)
35 }
36 return resp.StatusCode, out
37}
38
39func TestJSONAPI(t *testing.T) {
40 inst := startInstanceWith(t, "[api]\nenabled = true\n")
41 aliceKey := inst.newKey(t, "alice")
42 inst.admin(t, "admin", "user", "create", "alice",
43 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
44
45 // Tokens are minted over SSH, shown once.
46 out, errOut, code := inst.ssh(t, aliceKey, "", "token", "create", "--name", "ci", "--json")
47 if code != 0 {
48 t.Fatalf("token create: %s", errOut)
49 }
50 var env struct {
51 Data struct {
52 Token string `json:"token"`
53 } `json:"data"`
54 }
55 if err := json.Unmarshal([]byte(out), &env); err != nil || !strings.HasPrefix(env.Data.Token, "gb_") {
56 t.Fatalf("token create output: %v %s", err, out)
57 }
58 token := env.Data.Token
59
60 // Auth failures are uniform 401s.
61 if status, _ := inst.apiCall(t, "", []string{"whoami"}, ""); status != 401 {
62 t.Fatalf("no token: %d", status)
63 }
64 if status, _ := inst.apiCall(t, "gb_wrong", []string{"whoami"}, ""); status != 401 {
65 t.Fatalf("bad token: %d", status)
66 }
67
68 // whoami through the API: same envelope, exit_code injected.
69 status, body := inst.apiCall(t, token, []string{"whoami"}, "")
70 if status != 200 || body["exit_code"].(float64) != 0 {
71 t.Fatalf("whoami: %d %v", status, body)
72 }
73 if data := body["data"].(map[string]any); data["username"] != "alice" {
74 t.Fatalf("whoami data: %v", body)
75 }
76
77 // Mutations work: create a repo and an issue, then read it back.
78 if status, body = inst.apiCall(t, token, []string{"repo", "create", "alice/proj", "--private"}, ""); status != 200 {
79 t.Fatalf("repo create: %d %v", status, body)
80 }
81 if status, _ = inst.apiCall(t, token, []string{"issue", "create", "alice/proj", "--title", "from the api", "--file", "-"}, "body via stdin\n"); status != 200 {
82 t.Fatal("issue create failed")
83 }
84 status, body = inst.apiCall(t, token, []string{"issue", "show", "alice/proj", "1"}, "")
85 data := body["data"].(map[string]any)
86 if status != 200 || data["title"] != "from the api" || data["body"] != "body via stdin\n" {
87 t.Fatalf("issue show: %d %v", status, body)
88 }
89
90 // Exit codes map to HTTP statuses.
91 if status, _ = inst.apiCall(t, token, []string{"issue", "show", "alice/proj", "99"}, ""); status != 404 {
92 t.Fatalf("missing issue: %d", status)
93 }
94 if status, _ = inst.apiCall(t, token, []string{"nonsense"}, ""); status != 400 {
95 t.Fatalf("unknown command: %d", status)
96 }
97
98 // Raw-output commands (no envelope) are wrapped.
99 status, body = inst.apiCall(t, token, []string{"help"}, "")
100 if status != 200 || !strings.Contains(body["output"].(string), "repo create") {
101 t.Fatalf("help via API: %d %v", status, body)
102 }
103
104 // Git transport is refused by name.
105 if status, _ = inst.apiCall(t, token, []string{"git-upload-pack", "alice/proj"}, ""); status != 400 {
106 t.Fatalf("git over API: %d", status)
107 }
108
109 // Token management never works over the API: no credential minting.
110 status, body = inst.apiCall(t, token, []string{"token", "create", "--name", "sneaky"}, "")
111 if status != 403 || !strings.Contains(body["error"].(string), "only available over SSH") {
112 t.Fatalf("token create via API: %d %v", status, body)
113 }
114
115 // Read-scoped tokens read but never write.
116 out, _, code = inst.ssh(t, aliceKey, "", "token", "create", "--name", "reader", "--scope", "read", "--json")
117 if code != 0 {
118 t.Fatal("read token create failed")
119 }
120 json.Unmarshal([]byte(out), &env)
121 readToken := env.Data.Token
122 if status, _ = inst.apiCall(t, readToken, []string{"issue", "list", "alice/proj"}, ""); status != 200 {
123 t.Fatalf("read token list: %d", status)
124 }
125 status, body = inst.apiCall(t, readToken, []string{"issue", "close", "alice/proj", "1"}, "")
126 if status != 403 || !strings.Contains(body["error"].(string), "read-only") {
127 t.Fatalf("read token write: %d %v", status, body)
128 }
129
130 // Expiry: a 1-second token dies.
131 out, _, _ = inst.ssh(t, aliceKey, "", "token", "create", "--name", "brief", "--ttl", "1s", "--json")
132 json.Unmarshal([]byte(out), &env)
133 brief := env.Data.Token
134 if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 {
135 t.Fatal("fresh short-ttl token rejected")
136 }
137 time.Sleep(1100 * time.Millisecond)
138 if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 401 {
139 t.Fatal("expired token accepted")
140 }
141
142 // Revocation kills a token immediately.
143 if _, _, code = inst.ssh(t, aliceKey, "", "token", "revoke", "ci"); code != 0 {
144 t.Fatal("revoke failed")
145 }
146 if status, _ = inst.apiCall(t, token, []string{"whoami"}, ""); status != 401 {
147 t.Fatal("revoked token accepted")
148 }
149
150 // With [api] disabled (the default), the endpoint does not exist.
151 inst2 := startInstance(t)
152 req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", inst2.httpPort),
153 strings.NewReader(`{"argv":["whoami"]}`))
154 req.Header.Set("Authorization", "Bearer gb_x")
155 resp, err := http.DefaultClient.Do(req)
156 if err != nil {
157 t.Fatal(err)
158 }
159 resp.Body.Close()
160 if resp.StatusCode != 404 {
161 t.Fatalf("API on disabled instance: %d, want 404", resp.StatusCode)
162 }
163}
internal/config/config.go +8
@@ -19,6 +19,7 @@ type Config struct {
1919 GitDaemon GitDaemon `toml:"git_daemon"`
2020 Web Web `toml:"web"`
2121 Registration Registration `toml:"registration"`
22 API API `toml:"api"`
2223 Limits Limits `toml:"limits"`
2324 Mail Mail `toml:"mail"`
2425}
@@ -61,6 +62,13 @@ type Registration struct {
6162 Mode string `toml:"mode"` // closed | invite | open
6263}
6364
65// API controls the HTTPS/JSON control-plane API (bearer tokens minted over
66// SSH). Off by default: an instance that never enables it has no
67// credential-bearing HTTP surface at all.
68type API struct {
69 Enabled bool `toml:"enabled"`
70}
71
6472type Limits struct {
6573 MaxPackBytes int64 `toml:"max_pack_bytes"`
6674 MaxBlobBytes int64 `toml:"max_blob_bytes"`
internal/control/control.go +17 −2
@@ -24,12 +24,20 @@ type Ctx struct {
2424 Stdout io.Writer
2525 Stderr io.Writer
2626 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
2733}
2834
2935type Command struct {
3036 Path []string // e.g. ["keys", "add"]
3137 Summary string
3238 ReadsStdin bool
39 ReadOnly bool // safe for read-scoped API tokens
40 SSHOnly bool // refused over the HTTP API (credential minting)
3341 Run func(c *Ctx, args []string) int
3442}
3543
@@ -70,6 +78,12 @@ func Dispatch(c *Ctx, argv []string) int {
7078 if c.Scope != "full" {
7179 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope)
7280 }
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 }
7387 if c.User.Pending && !pendingAllowed(cmd.Path) {
7488 return c.fail(protocol.ExitDenied,
7589 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
@@ -132,8 +146,9 @@ func (c *Ctx) fail(code int, format string, args ...any) int {
132146
133147func init() {
134148 register(Command{
135 Path: []string{"help"},
136 Summary: "list available commands",
149 Path: []string{"help"},
150 Summary: "list available commands",
151 ReadOnly: true,
137152 Run: func(c *Ctx, args []string) int {
138153 for _, cmd := range registry {
139154 fmt.Fprintf(c.Stdout, "%-24s %s\n", joinPath(cmd.Path), cmd.Summary)
internal/control/identity.go +4 −2
@@ -15,12 +15,14 @@ func init() {
1515 register(Command{
1616 Path: []string{"whoami"},
1717 Summary: "show the authenticated account",
18 Run: runWhoami,
18 ReadOnly: true,
19 Run: runWhoami,
1920 })
2021 register(Command{
2122 Path: []string{"keys", "list"},
2223 Summary: "list registered SSH keys",
23 Run: runKeysList,
24 ReadOnly: true,
25 Run: runKeysList,
2426 })
2527 register(Command{
2628 Path: []string{"keys", "add"},
internal/control/issue.go +2 −2
@@ -19,9 +19,9 @@ func init() {
1919 Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -]",
2020 ReadsStdin: true, Run: runIssueCreate})
2121 register(Command{Path: []string{"issue", "list"},
22 Summary: "list issues: issue list <owner/name> [--state open|closed|all]", Run: runIssueList})
22 Summary: "list issues: issue list <owner/name> [--state open|closed|all]", ReadOnly: true, Run: runIssueList})
2323 register(Command{Path: []string{"issue", "show"},
24 Summary: "show an issue with comments: issue show <owner/name> <n>", Run: runIssueShow})
24 Summary: "show an issue with comments: issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow})
2525 register(Command{Path: []string{"issue", "comment"},
2626 Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -]",
2727 ReadsStdin: true, Run: runIssueComment})
internal/control/mr.go +3 −3
@@ -22,11 +22,11 @@ func init() {
2222 Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]",
2323 ReadsStdin: true, Run: runMRCreate})
2424 register(Command{Path: []string{"mr", "list"},
25 Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", Run: runMRList})
25 Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", ReadOnly: true, Run: runMRList})
2626 register(Command{Path: []string{"mr", "show"},
27 Summary: "show a merge request: mr show <owner/name> <n>", Run: runMRShow})
27 Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
2828 register(Command{Path: []string{"mr", "diff"},
29 Summary: "show the diff: mr diff <owner/name> <n>", Run: runMRDiff})
29 Summary: "show the diff: mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff})
3030 register(Command{Path: []string{"mr", "comment"},
3131 Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -]",
3232 ReadsStdin: true, Run: runMRComment})
internal/control/org.go +3 −3
@@ -14,9 +14,9 @@ func init() {
1414 register(Command{Path: []string{"org", "create"},
1515 Summary: "create an organization (you become its first admin): org create <name>", Run: runOrgCreate})
1616 register(Command{Path: []string{"org", "list"},
17 Summary: "list organizations you belong to", Run: runOrgList})
17 Summary: "list organizations you belong to", ReadOnly: true, Run: runOrgList})
1818 register(Command{Path: []string{"org", "show"},
19 Summary: "show an organization and its members: org show <name>", Run: runOrgShow})
19 Summary: "show an organization and its members: org show <name>", ReadOnly: true, Run: runOrgShow})
2020 register(Command{Path: []string{"org", "delete"},
2121 Summary: "delete an empty organization: org delete <name> --yes", Run: runOrgDelete})
2222 register(Command{Path: []string{"org", "members", "add"},
@@ -24,7 +24,7 @@ func init() {
2424 register(Command{Path: []string{"org", "members", "remove"},
2525 Summary: "remove a member: org members remove <org> <user>", Run: runOrgMembersRemove})
2626 register(Command{Path: []string{"org", "members", "list"},
27 Summary: "list members: org members list <org>", Run: runOrgMembersList})
27 Summary: "list members: org members list <org>", ReadOnly: true, Run: runOrgMembersList})
2828}
2929
3030// orgAdmin loads an org and requires the caller to be one of its admins.
internal/control/repo.go +4 −4
@@ -27,9 +27,9 @@ func init() {
2727 register(Command{Path: []string{"repo", "create"},
2828 Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate})
2929 register(Command{Path: []string{"repo", "list"},
30 Summary: "list repositories you own or can access", Run: runRepoList})
30 Summary: "list repositories you own or can access", ReadOnly: true, Run: runRepoList})
3131 register(Command{Path: []string{"repo", "show"},
32 Summary: "show repository details: repo show <owner/name>", Run: runRepoShow})
32 Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow})
3333 register(Command{Path: []string{"repo", "delete"},
3434 Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete})
3535 register(Command{Path: []string{"repo", "access", "grant"},
@@ -37,9 +37,9 @@ func init() {
3737 register(Command{Path: []string{"repo", "access", "revoke"},
3838 Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke})
3939 register(Command{Path: []string{"repo", "access", "list"},
40 Summary: "list access grants: repo access list <owner/name>", Run: runAccessList})
40 Summary: "list access grants: repo access list <owner/name>", ReadOnly: true, Run: runAccessList})
4141 register(Command{Path: []string{"repo", "settings", "show"},
42 Summary: "show settings: repo settings show <owner/name>", Run: runSettingsShow})
42 Summary: "show settings: repo settings show <owner/name>", ReadOnly: true, Run: runSettingsShow})
4343 register(Command{Path: []string{"repo", "settings", "protect"},
4444 Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
4545 register(Command{Path: []string{"repo", "settings", "unprotect"},
internal/control/sig.go +2 −2
@@ -19,11 +19,11 @@ func init() {
1919 register(Command{Path: []string{"pgp", "add"},
2020 Summary: "register an OpenPGP public key (armored, on stdin)", ReadsStdin: true, Run: runPGPAdd})
2121 register(Command{Path: []string{"pgp", "list"},
22 Summary: "list registered OpenPGP keys", Run: runPGPList})
22 Summary: "list registered OpenPGP keys", ReadOnly: true, Run: runPGPList})
2323 register(Command{Path: []string{"pgp", "remove"},
2424 Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove})
2525 register(Command{Path: []string{"repo", "log"},
26 Summary: "commit log with signature states: repo log <owner/name> [--limit n]", Run: runRepoLog})
26 Summary: "commit log with signature states: repo log <owner/name> [--limit n]", ReadOnly: true, Run: runRepoLog})
2727}
2828
2929func runPGPAdd(c *Ctx, args []string) int {
internal/control/token.go added +131
@@ -0,0 +1,131 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "strconv"
8 "strings"
9 "time"
10
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15func init() {
16 register(Command{Path: []string{"token", "create"},
17 Summary: "mint an API token (shown once): token create --name <n> [--scope full|read] [--ttl 30d|720h]",
18 SSHOnly: true, Run: runTokenCreate})
19 register(Command{Path: []string{"token", "list"},
20 Summary: "list API tokens", ReadOnly: true, SSHOnly: true, Run: runTokenList})
21 register(Command{Path: []string{"token", "revoke"},
22 Summary: "revoke an API token by name: token revoke <name>",
23 SSHOnly: true, Run: runTokenRevoke})
24}
25
26// parseTTL accepts Go durations plus a day suffix ("30d").
27func parseTTL(s string) (time.Duration, error) {
28 if days, ok := strings.CutSuffix(s, "d"); ok {
29 n, err := strconv.Atoi(days)
30 if err != nil || n < 1 {
31 return 0, fmt.Errorf("bad ttl %q", s)
32 }
33 return time.Duration(n) * 24 * time.Hour, nil
34 }
35 return time.ParseDuration(s)
36}
37
38func runTokenCreate(c *Ctx, args []string) int {
39 name, scope, ttl := "", "full", ""
40 for i := 0; i < len(args); i++ {
41 switch args[i] {
42 case "--name", "--scope", "--ttl":
43 if i+1 >= len(args) {
44 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
45 }
46 switch args[i] {
47 case "--name":
48 name = args[i+1]
49 case "--scope":
50 scope = args[i+1]
51 case "--ttl":
52 ttl = args[i+1]
53 }
54 i++
55 default:
56 return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]")
57 }
58 }
59 if name == "" || (scope != "full" && scope != "read") {
60 return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]")
61 }
62 var expires *time.Time
63 if ttl != "" {
64 d, err := parseTTL(ttl)
65 if err != nil {
66 return c.fail(protocol.ExitUsage, "%v", err)
67 }
68 t := time.Now().Add(d)
69 expires = &t
70 }
71 raw, _, err := store.NewToken()
72 if err != nil {
73 return c.fail(protocol.ExitFailure, "%v", err)
74 }
75 // The gb_ prefix makes leaked tokens findable by secret scanners.
76 token := "gb_" + raw
77 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil {
78 return c.fail(protocol.ExitUsage, "%v", err)
79 }
80 type out struct {
81 Name string `json:"name"`
82 Scope string `json:"scope"`
83 Token string `json:"token"`
84 }
85 d := out{name, scope, token}
86 return c.emit(d, func(w io.Writer) {
87 fmt.Fprintf(w, "token %q (%s) — shown once, store it now:\n%s\n", d.Name, d.Scope, d.Token)
88 })
89}
90
91func runTokenList(c *Ctx, args []string) int {
92 tokens, err := c.Store.ListAPITokens(c.User.ID)
93 if err != nil {
94 return c.fail(protocol.ExitFailure, "%v", err)
95 }
96 type out struct {
97 Name string `json:"name"`
98 Scope string `json:"scope"`
99 CreatedAt string `json:"created_at"`
100 ExpiresAt *time.Time `json:"expires_at,omitempty"`
101 LastUsedAt *time.Time `json:"last_used_at,omitempty"`
102 }
103 var ds []out
104 for _, t := range tokens {
105 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
106 }
107 return c.emit(ds, func(w io.Writer) {
108 for _, d := range ds {
109 exp := "never expires"
110 if d.ExpiresAt != nil {
111 exp = "expires " + d.ExpiresAt.UTC().Format(time.RFC3339)
112 }
113 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Name, d.Scope, exp)
114 }
115 })
116}
117
118func runTokenRevoke(c *Ctx, args []string) int {
119 if len(args) != 1 {
120 return c.fail(protocol.ExitUsage, "usage: token revoke <name>")
121 }
122 if err := c.Store.RevokeAPIToken(c.User.ID, args[0]); err != nil {
123 if errors.Is(err, store.ErrNotFound) {
124 return c.fail(protocol.ExitNotFound, "no token named %q", args[0])
125 }
126 return c.fail(protocol.ExitFailure, "%v", err)
127 }
128 return c.emit(map[string]string{"revoked": args[0]}, func(w io.Writer) {
129 fmt.Fprintf(w, "revoked %s\n", args[0])
130 })
131}
internal/httpd/api.go added +120
@@ -0,0 +1,120 @@
1package httpd
2
3import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "io"
8 "net/http"
9 "strings"
10
11 "gitbay.org/gitbay/internal/control"
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// apiRequest is the wire form of one command invocation. argv is real
17// argv — no shell, no tokenizer, no quoting rules.
18type apiRequest struct {
19 Argv []string `json:"argv"`
20 Stdin string `json:"stdin,omitempty"`
21}
22
23const maxAPIBody = 1 << 20
24
25// apiCmd fronts the same control-command registry the SSH dispatcher uses:
26// every command, current and future, is reachable here with identical
27// semantics. Exit codes map onto HTTP statuses; the body is the command's
28// JSON envelope with exit_code added.
29func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) {
30 user, scope, ok := s.apiAuth(w, r)
31 if !ok {
32 return
33 }
34
35 var req apiRequest
36 if err := json.NewDecoder(io.LimitReader(r.Body, maxAPIBody)).Decode(&req); err != nil {
37 apiError(w, http.StatusBadRequest, "body must be JSON: {\"argv\": [...], \"stdin\": \"...\"}")
38 return
39 }
40 if len(req.Argv) == 0 {
41 apiError(w, http.StatusBadRequest, "argv is required")
42 return
43 }
44 switch req.Argv[0] {
45 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
46 apiError(w, http.StatusBadRequest, "git transport does not run over the JSON API; use git with an SSH remote")
47 return
48 }
49
50 var stdout, stderr bytes.Buffer
51 ctx := &control.Ctx{
52 User: user,
53 Scope: "full", // key scopes are an SSH concept; token scope is below
54 Store: s.st,
55 Cfg: s.cfg,
56 Stdin: strings.NewReader(req.Stdin),
57 Stdout: &stdout,
58 Stderr: &stderr,
59 JSON: true,
60 ViaAPI: true,
61 ReadOnly: scope == "read",
62 }
63 code := control.Dispatch(ctx, req.Argv)
64
65 status := map[int]int{
66 protocol.ExitOK: http.StatusOK,
67 protocol.ExitUsage: http.StatusBadRequest,
68 protocol.ExitNotFound: http.StatusNotFound,
69 protocol.ExitDenied: http.StatusForbidden,
70 }[code]
71 if status == 0 {
72 status = http.StatusInternalServerError
73 }
74
75 // Commands normally emit exactly one JSON envelope; inject exit_code.
76 // A few (mr diff, help) write raw text instead — wrap those.
77 var body map[string]any
78 if err := json.Unmarshal(stdout.Bytes(), &body); err != nil || body == nil {
79 body = map[string]any{
80 "protocol_version": protocol.Version,
81 "output": stdout.String(),
82 }
83 }
84 body["exit_code"] = code
85 if msg := strings.TrimSpace(stderr.String()); msg != "" {
86 body["stderr"] = msg
87 }
88 w.Header().Set("Content-Type", "application/json")
89 w.WriteHeader(status)
90 json.NewEncoder(w).Encode(body)
91}
92
93// apiAuth resolves the bearer token; failures are uniform 401s.
94func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, string, bool) {
95 token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
96 if !ok || token == "" {
97 w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`)
98 apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>")
99 return store.User{}, "", false
100 }
101 user, scope, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
102 if err != nil {
103 if errors.Is(err, store.ErrNotFound) {
104 apiError(w, http.StatusUnauthorized, "invalid or expired token")
105 return store.User{}, "", false
106 }
107 apiError(w, http.StatusInternalServerError, "internal error")
108 return store.User{}, "", false
109 }
110 return user, scope, true
111}
112
113func apiError(w http.ResponseWriter, status int, msg string) {
114 w.Header().Set("Content-Type", "application/json")
115 w.WriteHeader(status)
116 json.NewEncoder(w).Encode(map[string]any{
117 "protocol_version": protocol.Version,
118 "error": msg,
119 })
120}
internal/httpd/routes.go +7
@@ -43,6 +43,13 @@ func (s *Server) Routes() []Route {
4343 Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr},
4444 )
4545
46 // The JSON API is its own opt-in surface, independent of web.mode.
47 if s.cfg.API.Enabled {
48 routes = append(routes,
49 Route{Method: "POST", Pattern: "/api/v1/cmd", Mutating: true, Handler: s.apiCmd},
50 )
51 }
52
4653 // Account-mode routes exist only when web.mode = "accounts". In
4754 // view_only they are never registered — the structural guarantee.
4855 if s.cfg.Web.Mode == "accounts" {
internal/httpd/routes_test.go +20
@@ -33,6 +33,26 @@ func TestViewOnlyHasNoMutatingRoutes(t *testing.T) {
3333 }
3434}
3535
36// TestAPIRouteGating: the API route exists only when [api] enabled = true.
37func TestAPIRouteGating(t *testing.T) {
38 has := func(cfg config.Config) bool {
39 for _, r := range New(cfg, nil).Routes() {
40 if r.Pattern == "/api/v1/cmd" {
41 return true
42 }
43 }
44 return false
45 }
46 if has(config.Default()) {
47 t.Fatal("API route present with api disabled (the default)")
48 }
49 cfg := config.Default()
50 cfg.API.Enabled = true
51 if !has(cfg) {
52 t.Fatal("API route missing with api enabled")
53 }
54}
55
3656// TestAccountsModeHasLoginRoute is the positive counterpart: switching the
3757// mode on registers the session routes.
3858func TestAccountsModeHasLoginRoute(t *testing.T) {
internal/store/migrations/0004_api_tokens.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE api_tokens;
internal/store/migrations/0004_api_tokens.up.sql added +11
@@ -0,0 +1,11 @@
1CREATE TABLE api_tokens (
2 id INTEGER PRIMARY KEY,
3 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4 name TEXT NOT NULL,
5 token_hash TEXT NOT NULL UNIQUE,
6 scope TEXT NOT NULL DEFAULT 'full' CHECK (scope IN ('full','read')),
7 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
8 expires_at TEXT,
9 last_used_at TEXT,
10 UNIQUE (user_id, name)
11);
internal/store/tokens.go added +83
@@ -0,0 +1,83 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "time"
8)
9
10type APIToken struct {
11 Name string
12 Scope string
13 CreatedAt string
14 ExpiresAt *time.Time
15 LastUsedAt *time.Time
16}
17
18// CreateAPIToken stores a token hash; expires nil means no expiry.
19func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time) error {
20 var exp any
21 if expires != nil {
22 exp = fmtTime(*expires)
23 }
24 _, err := s.DB.Exec(
25 "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?, ?)",
26 userID, name, tokenHash, scope, exp)
27 if isUniqueErr(err) {
28 return fmt.Errorf("you already have a token named %q", name)
29 }
30 return err
31}
32
33// APITokenUser resolves a presented token to its user and scope; expired and
34// unknown tokens fail identically.
35func (s *Store) APITokenUser(tokenHash string) (User, string, error) {
36 var userID int64
37 var scope string
38 err := s.DB.QueryRow(`
39 SELECT user_id, scope FROM api_tokens
40 WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`,
41 tokenHash, fmtTime(time.Now())).Scan(&userID, &scope)
42 if errors.Is(err, sql.ErrNoRows) {
43 return User{}, "", ErrNotFound
44 }
45 if err != nil {
46 return User{}, "", err
47 }
48 s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash)
49 u, err := s.UserByID(userID)
50 return u, scope, err
51}
52
53func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
54 rows, err := s.DB.Query(
55 "SELECT name, scope, created_at, expires_at, last_used_at FROM api_tokens WHERE user_id = ? ORDER BY name", userID)
56 if err != nil {
57 return nil, err
58 }
59 defer rows.Close()
60 var out []APIToken
61 for rows.Next() {
62 var t APIToken
63 var exp, used sql.NullString
64 if err := rows.Scan(&t.Name, &t.Scope, &t.CreatedAt, &exp, &used); err != nil {
65 return nil, err
66 }
67 t.ExpiresAt = parseTime(exp)
68 t.LastUsedAt = parseTime(used)
69 out = append(out, t)
70 }
71 return out, rows.Err()
72}
73
74func (s *Store) RevokeAPIToken(userID int64, name string) error {
75 res, err := s.DB.Exec("DELETE FROM api_tokens WHERE user_id = ? AND name = ?", userID, name)
76 if err != nil {
77 return err
78 }
79 if n, _ := res.RowsAffected(); n == 0 {
80 return ErrNotFound
81 }
82 return nil
83}