internal/control/identity.go
214 lines · 6205 bytes
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "strings"
8 "unicode"
9
10 "golang.org/x/crypto/ssh"
11
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14)
15
16func init() {
17 register(Command{
18 Path: []string{"whoami"},
19 Summary: "show the authenticated account",
20 Usage: "whoami",
21 Examples: []string{"whoami"},
22 ReadOnly: true,
23 Run: runWhoami,
24 })
25 register(Command{
26 Path: []string{"keys", "list"},
27 Summary: "list registered SSH keys",
28 Usage: "keys list",
29 Examples: []string{"keys list"},
30 ReadOnly: true,
31 Run: runKeysList,
32 })
33 register(Command{
34 Path: []string{"keys", "add"},
35 Summary: "register an SSH public key (authorized_keys format)",
36 Usage: "keys add [--scope full|git|runner] [--label <text>] < key.pub",
37 Flags: []Flag{
38 {"--scope", "full|git|runner", "what the key may do", "full"},
39 {"--label", "<text>", "a name for the key", ""},
40 },
41 Examples: []string{"keys add --label laptop < key.pub"},
42 ReadsStdin: true,
43 Run: runKeysAdd,
44 })
45 register(Command{
46 Path: []string{"keys", "label"},
47 Summary: "name a key; an empty label clears it",
48 Usage: "keys label <fingerprint> [<text>]",
49 Examples: []string{`keys label SHA256:abcd1234 "work laptop"`},
50 Run: runKeysLabel,
51 })
52 register(Command{
53 Path: []string{"keys", "remove"},
54 Summary: "remove an SSH key by fingerprint",
55 Usage: "keys remove <fingerprint>",
56 Examples: []string{"keys remove SHA256:abcd1234"},
57 Run: runKeysRemove,
58 })
59}
60
61func runWhoami(c *Ctx, args []string) int {
62 if len(args) != 0 {
63 return c.usage()
64 }
65 type out struct {
66 Username string `json:"username"`
67 Admin bool `json:"admin"`
68 KeyScope string `json:"key_scope"`
69 }
70 d := out{Username: c.User.Username, Admin: c.User.IsAdmin, KeyScope: c.Scope}
71 return c.emit(d, func(w io.Writer) {
72 fmt.Fprintln(w, d.Username)
73 })
74}
75
76func runKeysList(c *Ctx, args []string) int {
77 if len(args) != 0 {
78 return c.usage()
79 }
80 keys, err := c.Store.ListSSHKeys(c.User.ID)
81 if err != nil {
82 return c.fail(protocol.ExitFailure, "listing keys: %v", err)
83 }
84 type out struct {
85 Fingerprint string `json:"fingerprint"`
86 Algo string `json:"algo"`
87 Scope string `json:"scope"`
88 Label string `json:"label"`
89 }
90 var ds []out
91 for _, k := range keys {
92 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label})
93 }
94 return c.emit(ds, func(w io.Writer) {
95 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL")
96 for _, d := range ds {
97 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label))
98 }
99 tb.flush()
100 })
101}
102
103// maxKeyLabel bounds a key's name. Labels are display text, one line.
104const maxKeyLabel = 64
105
106// keyLabel normalises a label: surrounding space trimmed, control
107// characters refused, length capped. An empty result is a valid "no
108// label".
109func keyLabel(s string) (string, error) {
110 s = strings.TrimSpace(s)
111 if len(s) > maxKeyLabel {
112 return "", fmt.Errorf("label is longer than %d bytes", maxKeyLabel)
113 }
114 for _, r := range s {
115 if unicode.IsControl(r) {
116 return "", errors.New("label must be a single line of printable text")
117 }
118 }
119 return s, nil
120}
121
122func runKeysAdd(c *Ctx, args []string) int {
123 f, err := parseFlags(args, flagSpec{Values: []string{"--scope", "--label"}, MaxPos: 0, Usage: "keys add [--scope full|git|runner] [--label <text>] < key.pub"})
124 if err != nil {
125 return c.fail(protocol.ExitUsage, "%v", err)
126 }
127 scope := "full"
128 if f.Has("--scope") {
129 scope = f.Value("--scope")
130 }
131 if scope != "full" && scope != "git" && scope != "runner" {
132 // deploy:* scopes are granted via repo settings, not self-service.
133 return c.fail(protocol.ExitUsage, "scope must be full, git or runner")
134 }
135 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
136 if err != nil {
137 return c.fail(protocol.ExitFailure, "reading key: %v", err)
138 }
139 pub, comment, _, _, err := ssh.ParseAuthorizedKey(raw)
140 if err != nil {
141 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
142 }
143 // The key's own comment is the label unless --label says otherwise.
144 label := comment
145 if f.Has("--label") {
146 label = f.Value("--label")
147 }
148 if label, err = keyLabel(label); err != nil {
149 return c.fail(protocol.ExitUsage, "%v", err)
150 }
151 fp := ssh.FingerprintSHA256(pub)
152 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label); err != nil {
153 if errors.Is(err, store.ErrDuplicateKey) {
154 return c.failErr(err)
155 }
156 return c.fail(protocol.ExitFailure, "adding key: %v", err)
157 }
158 type out struct {
159 Fingerprint string `json:"fingerprint"`
160 Scope string `json:"scope"`
161 Label string `json:"label"`
162 }
163 d := out{fp, scope, label}
164 return c.emit(d, func(w io.Writer) {
165 if d.Label != "" {
166 fmt.Fprintf(w, "added %s (%s) %s\n", d.Fingerprint, d.Scope, d.Label)
167 return
168 }
169 fmt.Fprintf(w, "added %s (%s)\n", d.Fingerprint, d.Scope)
170 })
171}
172
173func runKeysLabel(c *Ctx, args []string) int {
174 if len(args) < 1 || len(args) > 2 {
175 return c.usage()
176 }
177 label := ""
178 if len(args) == 2 {
179 label = args[1]
180 }
181 label, err := keyLabel(label)
182 if err != nil {
183 return c.fail(protocol.ExitUsage, "%v", err)
184 }
185 if err := c.Store.SetSSHKeyLabel(c.User.ID, args[0], label); err != nil {
186 if errors.Is(err, store.ErrNotFound) {
187 return c.fail(protocol.ExitNotFound, "no key with fingerprint %s on your account", args[0])
188 }
189 return c.fail(protocol.ExitFailure, "labelling key: %v", err)
190 }
191 d := map[string]string{"fingerprint": args[0], "label": label}
192 return c.emit(d, func(w io.Writer) {
193 if label == "" {
194 fmt.Fprintf(w, "cleared label on %s\n", args[0])
195 return
196 }
197 fmt.Fprintf(w, "%s is now %q\n", args[0], label)
198 })
199}
200
201func runKeysRemove(c *Ctx, args []string) int {
202 if len(args) != 1 {
203 return c.usage()
204 }
205 if err := c.Store.RemoveSSHKey(c.User.ID, args[0]); err != nil {
206 if errors.Is(err, store.ErrNotFound) {
207 return c.fail(protocol.ExitNotFound, "no key with fingerprint %s on your account", args[0])
208 }
209 return c.fail(protocol.ExitFailure, "removing key: %v", err)
210 }
211 return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
212 fmt.Fprintf(w, "removed %s\n", args[0])
213 })
214}