Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 17m ago 4b8c9e1d
history blame raw 13.6 KiB
1package server_test
2
3import (
4 "bytes"
5 "encoding/json"
6 "net"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "rodent.software/vole/forge/access"
14)
15
16// adminSSH runs one `vole-admin` command over the SSH listener and returns
17// stdout, stderr, and the exit error.
18func (f *fixture) adminSSH(key string, stdin []byte, args ...string) (string, string, error) {
19 f.t.Helper()
20 host, port, err := net.SplitHostPort(f.sshAddr)
21 if err != nil {
22 f.t.Fatal(err)
23 }
24 argv := []string{
25 "-i", key,
26 "-o", "IdentitiesOnly=yes",
27 "-o", "StrictHostKeyChecking=no",
28 "-o", "UserKnownHostsFile=/dev/null",
29 "-o", "LogLevel=ERROR",
30 "-p", port,
31 "git@" + host,
32 "vole-admin",
33 }
34 argv = append(argv, args...)
35 cmd := exec.Command("ssh", argv...)
36 if stdin != nil {
37 cmd.Stdin = bytes.NewReader(stdin)
38 }
39 var out, errOut bytes.Buffer
40 cmd.Stdout, cmd.Stderr = &out, &errOut
41 err = cmd.Run()
42 return out.String(), errOut.String(), err
43}
44
45func TestRemoteAdminRequiresInstanceAdmin(t *testing.T) {
46 t.Parallel()
47 f := newFixture(t)
48 out, errOut, err := f.adminSSH(f.keyPath, nil, "account", "list")
49 if err == nil {
50 t.Fatalf("non-admin used the admin API: %s%s", out, errOut)
51 }
52 if !strings.Contains(strings.ToLower(out+errOut), "instance admin required") {
53 t.Fatalf("refusal did not explain the requirement:\nstdout: %s\nstderr: %s", out, errOut)
54 }
55}
56
57func TestRemoteAdminManagesAccountsAndRepos(t *testing.T) {
58 t.Parallel()
59 f := newFixture(t)
60 if _, err := f.srv.Identity.SetAdmin("keenan", true, "test"); err != nil {
61 t.Fatal(err)
62 }
63
64 out, errOut, err := f.adminSSH(f.keyPath, nil, "account", "create", "alice")
65 if err == nil {
66 t.Fatalf("account create without a key or --no-key succeeded: %s%s", out, errOut)
67 }
68
69 // --no-key is the explicit passkey-only path; it must not wait for stdin.
70 out, errOut, err = f.adminSSH(f.keyPath, nil, "account", "create", "bob", "--no-key")
71 if err != nil {
72 t.Fatalf("account create --no-key: %v\nstdout: %s\nstderr: %s", err, out, errOut)
73 }
74 if a, err := f.srv.Identity.Find("bob"); err != nil || len(a.Keys) != 0 {
75 t.Fatalf("passkey-only account has keys: %+v, %v", a, err)
76 }
77 if !strings.Contains(out, "enrollment") {
78 t.Fatalf("account create --no-key did not print an enrollment link: %s", out)
79 }
80 out, errOut, err = f.adminSSH(f.keyPath, nil, "account", "role", "bob", "contribute")
81 if err != nil {
82 t.Fatalf("account role: %v\nstdout: %s\nstderr: %s", err, out, errOut)
83 }
84 if a, err := f.srv.Identity.Find("bob"); err != nil || a.Role != access.Contribute {
85 t.Fatalf("remote role did not land: %+v, %v", a, err)
86 }
87
88 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_alice"))
89 out, errOut, err = f.adminSSH(f.keyPath, []byte(pub), "account", "create", "alice")
90 if err != nil {
91 t.Fatalf("account create: %v\nstdout: %s\nstderr: %s", err, out, errOut)
92 }
93 if !strings.Contains(out, "alice") {
94 t.Fatalf("account create did not name alice: %s", out)
95 }
96 if a, err := f.srv.Identity.Find("alice"); err != nil || len(a.Keys) != 1 {
97 t.Fatalf("account create did not attach the key: %+v, %v", a, err)
98 }
99
100 second, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_alice_second"))
101 out, errOut, err = f.adminSSH(f.keyPath, []byte(second), "account", "add-key", "alice")
102 if err != nil {
103 t.Fatalf("account add-key: %v\nstdout: %s\nstderr: %s", err, out, errOut)
104 }
105 if !strings.Contains(out, "added key") {
106 t.Fatalf("add-key output = %q", out)
107 }
108
109 out, errOut, err = f.adminSSH(f.keyPath, nil, "--json", "account", "list")
110 if err != nil {
111 t.Fatalf("account list --json: %v\nstdout: %s\nstderr: %s", err, out, errOut)
112 }
113 var listed struct {
114 OK bool `json:"ok"`
115 Data []struct {
116 Name string `json:"name"`
117 } `json:"data"`
118 }
119 if err := json.Unmarshal([]byte(out), &listed); err != nil {
120 t.Fatalf("account list --json is not JSON: %v\n%s", err, out)
121 }
122 found := false
123 for _, u := range listed.Data {
124 if u.Name == "alice" {
125 found = true
126 }
127 }
128 if !listed.OK || !found {
129 t.Fatalf("account list did not include alice: %s", out)
130 }
131
132 // Older clients still send the historical resource spelling.
133 out, errOut, err = f.adminSSH(f.keyPath, nil, "user", "list")
134 if err != nil || !strings.Contains(out, "alice") {
135 t.Fatalf("legacy user list: %v\nstdout: %s\nstderr: %s", err, out, errOut)
136 }
137
138 out, errOut, err = f.adminSSH(f.keyPath, nil, "repo", "create", "remote-demo", "--owner", "alice")
139 if err != nil {
140 t.Fatalf("repo create: %v\nstdout: %s\nstderr: %s", err, out, errOut)
141 }
142 if _, err := f.srv.Repos.Open("remote-demo"); err != nil {
143 t.Fatalf("repository was not created: %v", err)
144 }
145
146 out, errOut, err = f.adminSSH(f.keyPath, nil, "acl", "set-default-role", "remote-demo", "read")
147 if err != nil {
148 t.Fatalf("acl set-default-role: %v\nstdout: %s\nstderr: %s", err, out, errOut)
149 }
150 for _, args := range [][]string{
151 {"acl", "set-role", "remote-demo", "bob", "contribute"},
152 {"acl", "set-approvals", "remote-demo", "2"},
153 {"acl", "checks", "disable", "remote-demo"},
154 {"acl", "protect", "add", "remote-demo", "refs/heads/main"},
155 {"acl", "runners", "add", "remote-demo", "bob"},
156 } {
157 if out, errOut, err = f.adminSSH(f.keyPath, nil, args...); err != nil {
158 t.Fatalf("%v: %v\nstdout: %s\nstderr: %s", args, err, out, errOut)
159 }
160 }
161 m, err := f.srv.Repos.Open("remote-demo")
162 if err != nil {
163 t.Fatal(err)
164 }
165 a, err := m.ACL()
166 if err != nil {
167 t.Fatal(err)
168 }
169 if a.Default != "read" {
170 t.Fatalf("default role = %q, want read", a.Default)
171 }
172 if a.Entries["bob"] != "contribute" {
173 t.Fatalf("bob role = %q, want contribute", a.Entries["bob"])
174 }
175 if got := a.RequiredApprovals(); got != 2 {
176 t.Fatalf("approvals required = %d, want 2", got)
177 }
178 if a.RequiresChecks() {
179 t.Fatal("checks disable left checks required")
180 }
181 if got := strings.Join(a.Protected, ","); got != "refs/heads/main" {
182 t.Fatalf("protected = %q", got)
183 }
184 if got := strings.Join(a.Runners, ","); got != "bob" {
185 t.Fatalf("runners = %q", got)
186 }
187
188 // The remote surface applies the same rule as the local one: a pattern the
189 // hook can never match is refused and nothing is stored.
190 out, errOut, err = f.adminSSH(f.keyPath, nil, "acl", "protect", "add", "remote-demo", "refs/head/main")
191 if err == nil {
192 t.Fatalf("the remote admin stored a pattern that cannot match:\n%s", out)
193 }
194 if !strings.Contains(errOut, "refs/heads/main") {
195 t.Fatalf("the remote refusal does not name the fix:\n%s", errOut)
196 }
197 if fresh, err := f.srv.Repos.Open("remote-demo"); err != nil {
198 t.Fatal(err)
199 } else if acl, err := fresh.ACL(); err != nil {
200 t.Fatal(err)
201 } else if got := strings.Join(acl.Protected, ","); got != "refs/heads/main" {
202 t.Fatalf("the remote refusal changed the list: %q", got)
203 }
204
205 out, errOut, err = f.adminSSH(f.keyPath, nil, "repo", "rename", "remote-demo", "renamed-demo")
206 if err != nil {
207 t.Fatalf("repo rename: %v\nstdout: %s\nstderr: %s", err, out, errOut)
208 }
209 if _, err := f.srv.Repos.Open("renamed-demo"); err != nil {
210 t.Fatalf("renamed repository is missing: %v", err)
211 }
212
213 out, errOut, err = f.adminSSH(f.keyPath, nil, "repo", "delete", "renamed-demo")
214 if err != nil {
215 t.Fatalf("repo delete: %v\nstdout: %s\nstderr: %s", err, out, errOut)
216 }
217 if _, err := f.srv.Repos.Open("renamed-demo"); err == nil {
218 t.Fatal("deleted repository still exists")
219 }
220
221 out, errOut, err = f.adminSSH(f.keyPath, nil, "account", "retire", "alice")
222 if err != nil {
223 t.Fatalf("account retire: %v\nstdout: %s\nstderr: %s", err, out, errOut)
224 }
225 if a, err := f.srv.Identity.Find("alice"); err == nil && a.Active() {
226 t.Fatal("retired account is still active")
227 }
228}
229
230// A runner's account is a bot. The admin API creates one the same way the local
231// CLI does: --bot with a public key. The keyless form is refused because the
232// enrollment link a keyless account would get is a secret a bot cannot use.
233func TestRemoteAdminCreatesBots(t *testing.T) {
234 t.Parallel()
235 f := newFixture(t)
236 if _, err := f.srv.Identity.SetAdmin("keenan", true, "test"); err != nil {
237 t.Fatal(err)
238 }
239
240 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_ci_runner"))
241 out, errOut, err := f.adminSSH(f.keyPath, []byte(pub), "account", "create", "ci-runner", "--bot", "--role", "read")
242 if err != nil {
243 t.Fatalf("remote bot create: %v\nstdout: %s\nstderr: %s", err, out, errOut)
244 }
245 a, err := f.srv.Identity.Find("ci-runner")
246 if err != nil {
247 t.Fatal(err)
248 }
249 if !a.Bot() || len(a.Keys) != 1 || a.Role != access.Read {
250 t.Fatalf("remote bot = %+v", a)
251 }
252
253 // An enrollment link is the keyless path, and a bot has no browser to use it.
254 out, errOut, err = f.adminSSH(f.keyPath, nil, "account", "create", "keyless-bot", "--bot", "--no-key")
255 if err == nil {
256 t.Fatalf("a bot was created with an enrollment link: %s%s", out, errOut)
257 }
258 if !strings.Contains(out+errOut, "needs a key") {
259 t.Fatalf("refusal did not explain the key requirement:\nstdout: %s\nstderr: %s", out, errOut)
260 }
261 if a, err := f.srv.Identity.Find("keyless-bot"); err == nil && a != nil {
262 t.Fatal("the refused bot was created anyway")
263 }
264
265 // A bot cannot hold instance admin, so the create fails rather than making a
266 // machine that reaches every repository.
267 out, errOut, err = f.adminSSH(f.keyPath, []byte(pub), "account", "create", "admin-bot", "--bot", "--role", "admin")
268 if err == nil {
269 t.Fatalf("a bot was created as an instance admin: %s%s", out, errOut)
270 }
271 if a, err := f.srv.Identity.Find("admin-bot"); err == nil && a != nil {
272 t.Fatal("the refused admin bot was created anyway")
273 }
274}
275
276func TestAdminCLIForwardsToRemote(t *testing.T) {
277 t.Parallel()
278 f := newFixture(t)
279 if _, err := f.srv.Identity.SetAdmin("keenan", true, "test"); err != nil {
280 t.Fatal(err)
281 }
282 cmd := exec.Command(binPath, "admin", "--remote", "ssh://git@"+f.sshAddr, "account", "list")
283 cmd.Env = append(cmd.Environ(),
284 "VOLE_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
285 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
286 )
287 var out, errOut bytes.Buffer
288 cmd.Stdout, cmd.Stderr = &out, &errOut
289 if err := cmd.Run(); err != nil {
290 t.Fatalf("vole admin --remote: %v\nstdout: %s\nstderr: %s", err, out.String(), errOut.String())
291 }
292 if !strings.Contains(out.String(), "keenan") {
293 t.Fatalf("remote admin output did not list the admin:\n%s", out.String())
294 }
295
296 // The client reads a key file and sends it on stdin; the server never sees
297 // the client's filesystem.
298 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_cli_alice"))
299 pubPath := filepath.Join(f.cfg.DataDir, "id_cli_alice.pub")
300 if err := os.WriteFile(pubPath, []byte(pub), 0o600); err != nil {
301 t.Fatal(err)
302 }
303 cmd = exec.Command(binPath, "admin", "--remote", "ssh://git@"+f.sshAddr, "account", "create", "alice", "--key", pubPath)
304 cmd.Env = append(cmd.Environ(),
305 "VOLE_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
306 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
307 )
308 if err := cmd.Run(); err != nil {
309 t.Fatalf("remote account create: %v", err)
310 }
311 second, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_cli_alice_second"))
312 cmd = exec.Command(binPath, "admin", "--remote", "ssh://git@"+f.sshAddr, "account", "add-key", "alice")
313 cmd.Env = append(cmd.Environ(),
314 "VOLE_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
315 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
316 )
317 cmd.Stdin = strings.NewReader(second)
318 if err := cmd.Run(); err != nil {
319 t.Fatalf("remote account add-key: %v", err)
320 }
321 if a, err := f.srv.Identity.Find("alice"); err != nil || len(a.Keys) != 2 {
322 t.Fatalf("alice's keys did not land: %+v, %v", a, err)
323 }
324
325 // The client reads --key locally and still forwards --bot, so a runner on
326 // another machine can be given an account without a local instance.
327 botPub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_cli_ci_runner"))
328 botPubPath := filepath.Join(f.cfg.DataDir, "id_cli_ci_runner.pub")
329 if err := os.WriteFile(botPubPath, []byte(botPub), 0o600); err != nil {
330 t.Fatal(err)
331 }
332 cmd = exec.Command(binPath, "admin", "--remote", "ssh://git@"+f.sshAddr, "account", "create", "ci-runner", "--bot", "--key", botPubPath)
333 cmd.Env = append(cmd.Environ(),
334 "VOLE_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
335 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
336 )
337 if err := cmd.Run(); err != nil {
338 t.Fatalf("remote bot create via the CLI: %v", err)
339 }
340 if a, err := f.srv.Identity.Find("ci-runner"); err != nil || !a.Bot() || len(a.Keys) != 1 {
341 t.Fatalf("the remotely created bot = %+v, %v", a, err)
342 }
343}
344
345// An administrative change that cannot be recorded is not made. The log is the
346// only history of the roster, so a successful command with no line is the one
347// outcome the CLI must never report.
348func TestRemoteAdminRefusesWhenTheAuditLogIsUnwritable(t *testing.T) {
349 t.Parallel()
350 f := newFixture(t)
351 if _, err := f.srv.Identity.SetAdmin("keenan", true, "test"); err != nil {
352 t.Fatal(err)
353 }
354 // A directory where the log should be: opening it for append fails, which is
355 // also what a read-only data filesystem looks like.
356 logPath := f.cfg.AuditLog()
357 if err := os.RemoveAll(logPath); err != nil {
358 t.Fatal(err)
359 }
360 if err := os.Mkdir(logPath, 0o700); err != nil {
361 t.Fatal(err)
362 }
363
364 out, errOut, err := f.adminSSH(f.keyPath, nil, "account", "create", "eve", "--no-key")
365 if err == nil {
366 t.Fatalf("an unrecorded admin change succeeded: %s%s", out, errOut)
367 }
368 if !strings.Contains(out+errOut, "audit log could not be written") {
369 t.Fatalf("the refusal did not say why:\nstdout: %s\nstderr: %s", out, errOut)
370 }
371 if a, err := f.srv.Identity.Find("eve"); err == nil && a != nil {
372 t.Fatalf("the account was created anyway: %+v", a)
373 }
374}
375