sshd: removing a key closes its connections !480
20 files changed, +748 −61
Layout: unified · split
.gitbay/wiki/Architecture/05-Identity-and-Access.org +2 −2
| @@ -15,8 +15,8 @@ | |||
| 15 | 15 | ||
| 16 | | Credential | Format and generation | Stored as | Scope | Expiry | Revocation | | 16 | | Credential | Format and generation | Stored as | Scope | Expiry | Revocation | |
| 17 | |--------------------+-------------------------------------------------+----------------------------------+--------------------------------------------+-------------------------------+-------------------------------------| | 17 | |--------------------+-------------------------------------------------+----------------------------------+--------------------------------------------+-------------------------------+-------------------------------------| |
| 18 | | SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | none | =keys remove= (own keys) | | 18 | | SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | none | =keys remove= (own keys); closes its connections | |
| 19 | | Deploy key | public key | same table, scope =deploy:<repo>:ro/rw= | one repository, read or read-write | none | =repo deploy-key remove= (repo admin) | | 19 | | Deploy key | public key | same table, scope =deploy:<repo>:ro/rw= | one repository, read or read-write | none | =repo deploy-key remove= (repo admin); closes its connections | |
| 20 | | API token | =gb_= + 32 random bytes hex | SHA-256 hash | =full= or =read= | optional =--ttl= | =token revoke= | | 20 | | API token | =gb_= + 32 random bytes hex | SHA-256 hash | =full= or =read= | optional =--ttl= | =token revoke= | |
| 21 | | Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 7 days, no sliding renewal | logout, =web sessions revoke= | | 21 | | Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 7 days, no sliding renewal | logout, =web sessions revoke= | |
| 22 | | Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use | | 22 | | Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use | |
.gitbay/wiki/Architecture/08-Operations.org −3
| @@ -84,6 +84,3 @@ is preserved. | |||
| 84 | | Hide a repository | =admin repo visibility <repo> private= | | 84 | | Hide a repository | =admin repo visibility <repo> private= | |
| 85 | | Close registration | =registration.mode = "closed"= and restart | | 85 | | Close registration | =registration.mode = "closed"= and restart | |
| 86 | | See what happened | =audit= (filter by actor, action, time) | | 86 | | See what happened | =audit= (filter by actor, action, time) | |
| 87 | |||
| 88 | Open connections of a removed key keep working until they close; see | ||
| 89 | #256. | ||
.gitbay/wiki/Architecture/09-Controls.org +1 −1
| @@ -25,7 +25,7 @@ chapter names of OWASP ASVS 4.0 where one fits. | |||
| 25 | | Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) | | 25 | | Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) | |
| 26 | | Session lifetime | partial | 7 days absolute, no idle timeout (#276) | | 26 | | Session lifetime | partial | 7 days absolute, no idle timeout (#276) | |
| 27 | | Credential expiry | partial | API tokens optional; SSH and deploy keys none (#277) | | 27 | | Credential expiry | partial | API tokens optional; SSH and deploy keys none (#277) | |
| 28 | | Revocation takes effect immediately | gap | removed SSH key keeps open connections (#256) | | 28 | | Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=) | |
| 29 | | Delegation bounded by the delegating credential | gap | expiring tokens can mint lasting credentials (#257) | | 29 | | Delegation bounded by the delegating credential | gap | expiring tokens can mint lasting credentials (#257) | |
| 30 | 30 | ||
| 31 | ** Access control (V4) | 31 | ** Access control (V4) |
.gitbay/wiki/Architecture/10-Known-Gaps.org +1 −3
| @@ -11,7 +11,6 @@ what the 2026-09-27 review found; remove a row when its issue closes. | |||
| 11 | | Issue | Area | Gap | Severity | | 11 | | Issue | Area | Gap | Severity | |
| 12 | |-------+------------------+-----------------------------------------------------------------------+----------| | 12 | |-------+------------------+-----------------------------------------------------------------------+----------| |
| 13 | | #255 | CI isolation | Untrusted and trusted builds of a repository share a writable build home | high | | 13 | | #255 | CI isolation | Untrusted and trusted builds of a repository share a writable build home | high | |
| 14 | | #256 | Authentication | A removed SSH key keeps working on connections already open | high | | ||
| 15 | | #257 | Credentials | An expiring token can create credentials that outlive it; tokens default to full scope | high | | 14 | | #257 | Credentials | An expiring token can create credentials that outlive it; tokens default to full scope | high | |
| 16 | | #258 | CI integrity | Any writer can post a =ci/*= status; tree reuse ignores trust and image | high | | 15 | | #258 | CI integrity | Any writer can post a =ci/*= status; tree reuse ignores trust and image | high | |
| 17 | | #259 | Recovery | No restore has been exercised; verification does not check git connectivity | high | | 16 | | #259 | Recovery | No restore has been exercised; verification does not check git connectivity | high | |
| @@ -29,8 +28,7 @@ what the 2026-09-27 review found; remove a row when its issue closes. | |||
| 29 | | #281 | TLS | No explicit minimum TLS version | low | | 28 | | #281 | TLS | No explicit minimum TLS version | low | |
| 30 | | #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium | | 29 | | #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium | |
| 31 | 30 | ||
| 32 | Decisions already taken on these: #256 closes a removed key's | 31 | Decisions already taken on these: #257 refuses credential |
| 33 | connections, running commands included; #257 refuses credential | ||
| 34 | creation from expiring tokens, records which token created each | 32 | creation from expiring tokens, records which token created each |
| 35 | credential, and makes =read= the default scope. | 33 | credential, and makes =read= the default scope. |
| 36 | 34 | ||
.gitbay/wiki/Threat-Model.org +9
| @@ -36,6 +36,15 @@ matrix and the open gaps are in the [[file:Architecture/00-Overview.org][Archite | |||
| 36 | 36 | ||
| 37 | - *SSH public key = identity.* The SSH username is ignored; the presented | 37 | - *SSH public key = identity.* The SSH username is ignored; the presented |
| 38 | key's fingerprint resolves to an account. Key uniqueness is global. | 38 | key's fingerprint resolves to an account. Key uniqueness is global. |
| 39 | - *Revocation is immediate.* Every exec and every git transport session | ||
| 40 | re-reads its key. Removing a key, removing a deploy key, disabling or | ||
| 41 | deleting an account closes the connections the affected keys opened: | ||
| 42 | a git transport is killed with its children, and a push killed before | ||
| 43 | its pre-receive hook answers moves no ref. A control command already | ||
| 44 | inside its database write finishes it; its output is lost. A | ||
| 45 | revocation made by =gitbayd admin= on the host, another process, is | ||
| 46 | found within 15 seconds. In =ssh.mode = "system"= each exec is its | ||
| 47 | own process: the next exec is refused, one already running is not cut. | ||
| 39 | - *Per-instance trust.* Email verification and key registration are local | 48 | - *Per-instance trust.* Email verification and key registration are local |
| 40 | to an instance and never transfer. Account migration re-registers keys | 49 | to an instance and never transfer. Account migration re-registers keys |
| 41 | and re-verifies emails on the target by design. | 50 | and re-verifies emails on the target by design. |
.gitbay/wiki/Users.org +4
| @@ -69,6 +69,10 @@ A key's label is the comment on its =authorized_keys= line unless | |||
| 69 | =--label= gives one; =keys label= renames a key, and with no text | 69 | =--label= gives one; =keys label= renames a key, and with no text |
| 70 | clears the name. Labels are one line of up to 64 bytes. | 70 | clears the name. Labels are one line of up to 64 bytes. |
| 71 | 71 | ||
| 72 | Removing a key closes every connection it opened, including the CLI's | ||
| 73 | shared one; removing the key the current command runs on ends that | ||
| 74 | command's connection too. | ||
| 75 | |||
| 72 | Scopes: =full= (default; git plus every control command), =git= (git | 76 | Scopes: =full= (default; git plus every control command), =git= (git |
| 73 | transport only — right for automation keys, which then cannot touch | 77 | transport only — right for automation keys, which then cannot touch |
| 74 | issues, settings, or your account), or =runner= (the CI runner's | 78 | issues, settings, or your account), or =runner= (the CI runner's |
CHANGELOG.org +6
| @@ -4,6 +4,12 @@ Versioning follows semver from v0.1.0. Database migrations run | |||
| 4 | automatically on daemon start; upgrade notes appear per release when | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | anything beyond "replace the binary and restart" is needed. | 5 | anything beyond "replace the binary and restart" is needed. |
| 6 | 6 | ||
| 7 | * unreleased | ||
| 8 | |||
| 9 | - Removing an SSH key, removing a deploy key, or disabling or deleting an | ||
| 10 | account closes every open connection using an affected key, git | ||
| 11 | transports included; every command re-reads its key (#256). | ||
| 12 | |||
| 7 | * v1.36.0 — 2026-09-23 | 13 | * v1.36.0 — 2026-09-23 |
| 8 | 14 | ||
| 9 | Terminal output for the CLI (#254). | 15 | Terminal output for the CLI (#254). |
cmd/gitbayd/system.go +1 −1
| @@ -94,7 +94,7 @@ func shellCmd() *cobra.Command { | |||
| 94 | fmt.Fprintf(os.Stderr, "gitbay control plane: interactive shells are not available.\nTry: ssh <host> help\n") | 94 | fmt.Fprintf(os.Stderr, "gitbay control plane: interactive shells are not available.\nTry: ssh <host> help\n") |
| 95 | os.Exit(protocol.ExitUsage) | 95 | os.Exit(protocol.ExitUsage) |
| 96 | } | 96 | } |
| 97 | code := sshd.Exec(cfg, st, user, key.Scope, key.Fingerprint, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil) | 97 | code := sshd.Exec(cfg, st, user, key, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil, nil) |
| 98 | st.Close() | 98 | st.Close() |
| 99 | os.Exit(code) | 99 | os.Exit(code) |
| 100 | return nil | 100 | return nil |
e2e/revoke_test.go added +155
| @@ -0,0 +1,155 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | "os" | ||
| 8 | "os/exec" | ||
| 9 | "path/filepath" | ||
| 10 | "strconv" | ||
| 11 | "strings" | ||
| 12 | "testing" | ||
| 13 | "time" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // pkt frames one pkt-line. | ||
| 17 | func pkt(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) } | ||
| 18 | |||
| 19 | // readPkt reads one pkt-line; a flush reads as "". | ||
| 20 | func readPkt(r *bufio.Reader) (string, error) { | ||
| 21 | var n [4]byte | ||
| 22 | if _, err := io.ReadFull(r, n[:]); err != nil { | ||
| 23 | return "", err | ||
| 24 | } | ||
| 25 | size, err := strconv.ParseUint(string(n[:]), 16, 16) | ||
| 26 | if err != nil { | ||
| 27 | return "", err | ||
| 28 | } | ||
| 29 | if size == 0 { | ||
| 30 | return "", nil | ||
| 31 | } | ||
| 32 | buf := make([]byte, size-4) | ||
| 33 | _, err = io.ReadFull(r, buf) | ||
| 34 | return string(buf), err | ||
| 35 | } | ||
| 36 | |||
| 37 | // fingerprint is the SHA256 fingerprint of a public key file. | ||
| 38 | func fingerprint(t *testing.T, pubPath string) string { | ||
| 39 | t.Helper() | ||
| 40 | out, err := exec.Command("ssh-keygen", "-lf", pubPath).Output() | ||
| 41 | if err != nil { | ||
| 42 | t.Fatalf("ssh-keygen -lf: %v", err) | ||
| 43 | } | ||
| 44 | return strings.Fields(string(out))[1] | ||
| 45 | } | ||
| 46 | |||
| 47 | // Removing a key cuts the connections it opened: every session | ||
| 48 | // multiplexed on a ControlMaster, and a push in flight, which moves no | ||
| 49 | // ref (#256). | ||
| 50 | func TestRemovedKeyCutsMultiplexedConnection(t *testing.T) { | ||
| 51 | t.Parallel() | ||
| 52 | inst := startInstance(t) | ||
| 53 | aliceKey := setupPublicRepo(t, inst, "alice/app") | ||
| 54 | spare := inst.newKey(t, "spare") | ||
| 55 | pub, err := os.ReadFile(spare + ".pub") | ||
| 56 | if err != nil { | ||
| 57 | t.Fatal(err) | ||
| 58 | } | ||
| 59 | if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "keys", "add"); code != 0 { | ||
| 60 | t.Fatalf("keys add: %s", errOut) | ||
| 61 | } | ||
| 62 | |||
| 63 | // The control socket sits under the system temp dir with a short | ||
| 64 | // name: t.TempDir() or %C on macOS passes the 104-byte socket path | ||
| 65 | // limit. | ||
| 66 | cmDir, err := os.MkdirTemp("", "cm") | ||
| 67 | if err != nil { | ||
| 68 | t.Fatal(err) | ||
| 69 | } | ||
| 70 | t.Cleanup(func() { os.RemoveAll(cmDir) }) | ||
| 71 | muxArgs := []string{ | ||
| 72 | "-p", fmt.Sprint(inst.port), | ||
| 73 | "-i", aliceKey, | ||
| 74 | "-o", "IdentitiesOnly=yes", | ||
| 75 | "-o", "StrictHostKeyChecking=no", | ||
| 76 | "-o", "UserKnownHostsFile=" + filepath.Join(inst.sshDir, "known_hosts"), | ||
| 77 | "-o", "BatchMode=yes", | ||
| 78 | "-o", "ControlMaster=auto", | ||
| 79 | "-o", "ControlPath=" + filepath.Join(cmDir, "s"), | ||
| 80 | "-o", "ControlPersist=60", | ||
| 81 | } | ||
| 82 | mux := func(args ...string) *exec.Cmd { | ||
| 83 | return exec.Command("ssh", append(append([]string{}, muxArgs...), args...)...) | ||
| 84 | } | ||
| 85 | t.Cleanup(func() { mux("-O", "exit", "git@127.0.0.1").Run() }) | ||
| 86 | |||
| 87 | if out, err := mux("git@127.0.0.1", "whoami").Output(); err != nil || strings.TrimSpace(string(out)) != "alice" { | ||
| 88 | var stderr []byte | ||
| 89 | if ee, ok := err.(*exec.ExitError); ok { | ||
| 90 | stderr = ee.Stderr | ||
| 91 | } | ||
| 92 | t.Fatalf("whoami over the master: %v %q %s", err, out, stderr) | ||
| 93 | } | ||
| 94 | |||
| 95 | // A push held open mid-pack: the ref update is sent, the pack is not. | ||
| 96 | push := mux("git@127.0.0.1", "git-receive-pack", "alice/app") | ||
| 97 | stdin, err := push.StdinPipe() | ||
| 98 | if err != nil { | ||
| 99 | t.Fatal(err) | ||
| 100 | } | ||
| 101 | stdout, err := push.StdoutPipe() | ||
| 102 | if err != nil { | ||
| 103 | t.Fatal(err) | ||
| 104 | } | ||
| 105 | if err := push.Start(); err != nil { | ||
| 106 | t.Fatal(err) | ||
| 107 | } | ||
| 108 | t.Cleanup(func() { push.Process.Kill() }) | ||
| 109 | adv := bufio.NewReader(stdout) | ||
| 110 | first, err := readPkt(adv) | ||
| 111 | if err != nil || len(first) < 40 { | ||
| 112 | t.Fatalf("advertisement: %q %v", first, err) | ||
| 113 | } | ||
| 114 | oldSHA := first[:40] | ||
| 115 | for { | ||
| 116 | line, err := readPkt(adv) | ||
| 117 | if err != nil { | ||
| 118 | t.Fatalf("advertisement: %v", err) | ||
| 119 | } | ||
| 120 | if line == "" { | ||
| 121 | break | ||
| 122 | } | ||
| 123 | } | ||
| 124 | newSHA := strings.Repeat("1", 40) | ||
| 125 | io.WriteString(stdin, pkt(oldSHA+" "+newSHA+" refs/heads/main\x00report-status\n")+"0000") | ||
| 126 | // A pack header announcing one object, and no object. | ||
| 127 | stdin.Write([]byte("PACK\x00\x00\x00\x02\x00\x00\x00\x01")) | ||
| 128 | exited := make(chan error, 1) | ||
| 129 | go func() { | ||
| 130 | io.Copy(io.Discard, adv) | ||
| 131 | exited <- push.Wait() | ||
| 132 | }() | ||
| 133 | |||
| 134 | if _, errOut, code := inst.ssh(t, spare, "", "keys", "remove", fingerprint(t, aliceKey+".pub")); code != 0 { | ||
| 135 | t.Fatalf("keys remove: %s", errOut) | ||
| 136 | } | ||
| 137 | select { | ||
| 138 | case err := <-exited: | ||
| 139 | if err == nil { | ||
| 140 | t.Fatal("the push exited cleanly after its key was removed") | ||
| 141 | } | ||
| 142 | case <-time.After(10 * time.Second): | ||
| 143 | t.Fatal("the push outlived its key") | ||
| 144 | } | ||
| 145 | |||
| 146 | // The master went with the connection; a new one authenticates | ||
| 147 | // again, and the key is unknown. | ||
| 148 | if out, err := mux("git@127.0.0.1", "whoami").CombinedOutput(); err == nil { | ||
| 149 | t.Fatalf("whoami after removal succeeded: %s", out) | ||
| 150 | } | ||
| 151 | refs := mustGit(t, t.TempDir(), inst.gitEnv(spare), "ls-remote", inst.sshURL("alice/app"), "refs/heads/main") | ||
| 152 | if !strings.HasPrefix(refs, oldSHA) { | ||
| 153 | t.Fatalf("main moved: %s, want %s", refs, oldSHA) | ||
| 154 | } | ||
| 155 | } | ||
internal/gitutil/gitutil.go +19 −3
| @@ -35,8 +35,10 @@ func InitBare(path, defaultBranch, hooksPath string) error { | |||
| 35 | // Transport streams one git transport service (upload-pack, receive-pack, | 35 | // Transport streams one git transport service (upload-pack, receive-pack, |
| 36 | // upload-archive). extraEnv entries are appended to the process environment; | 36 | // upload-archive). extraEnv entries are appended to the process environment; |
| 37 | // hooks read the GITBAY_* variables from it. maxPack caps incoming pack | 37 | // hooks read the GITBAY_* variables from it. maxPack caps incoming pack |
| 38 | // bytes on receive-pack (0 = unlimited). | 38 | // bytes on receive-pack (0 = unlimited). Closing cancel kills the service |
| 39 | func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64) error { | 39 | // and everything it started; a push killed before its pre-receive hook |
| 40 | // answers updates no refs. A nil cancel never fires. | ||
| 41 | func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error { | ||
| 40 | var args []string | 42 | var args []string |
| 41 | switch service { | 43 | switch service { |
| 42 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": | 44 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": |
| @@ -52,7 +54,21 @@ func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer | |||
| 52 | cmd.Stdin = stdin | 54 | cmd.Stdin = stdin |
| 53 | cmd.Stdout = stdout | 55 | cmd.Stdout = stdout |
| 54 | cmd.Stderr = errW | 56 | cmd.Stderr = errW |
| 55 | return cmd.Run() | 57 | ownProcessGroup(cmd) |
| 58 | if err := cmd.Start(); err != nil { | ||
| 59 | return err | ||
| 60 | } | ||
| 61 | finished := make(chan struct{}) | ||
| 62 | go func() { | ||
| 63 | select { | ||
| 64 | case <-cancel: | ||
| 65 | killTree(cmd) | ||
| 66 | case <-finished: | ||
| 67 | } | ||
| 68 | }() | ||
| 69 | err := cmd.Wait() | ||
| 70 | close(finished) | ||
| 71 | return err | ||
| 56 | } | 72 | } |
| 57 | 73 | ||
| 58 | // IsAncestor reports whether old is an ancestor of new in the repository at | 74 | // IsAncestor reports whether old is an ancestor of new in the repository at |
internal/gitutil/proc_other.go added +13
| @@ -0,0 +1,13 @@ | |||
| 1 | //go:build !unix | ||
| 2 | |||
| 3 | package gitutil | ||
| 4 | |||
| 5 | import "os/exec" | ||
| 6 | |||
| 7 | func ownProcessGroup(cmd *exec.Cmd) {} | ||
| 8 | |||
| 9 | func killTree(cmd *exec.Cmd) { | ||
| 10 | if cmd.Process != nil { | ||
| 11 | cmd.Process.Kill() | ||
| 12 | } | ||
| 13 | } | ||
internal/gitutil/proc_unix.go added +26
| @@ -0,0 +1,26 @@ | |||
| 1 | //go:build unix | ||
| 2 | |||
| 3 | package gitutil | ||
| 4 | |||
| 5 | import ( | ||
| 6 | "os/exec" | ||
| 7 | "syscall" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // ownProcessGroup puts cmd in a process group of its own, so killTree | ||
| 11 | // ends what it started too: receive-pack runs index-pack and the hooks. | ||
| 12 | func ownProcessGroup(cmd *exec.Cmd) { | ||
| 13 | if cmd.SysProcAttr == nil { | ||
| 14 | cmd.SysProcAttr = &syscall.SysProcAttr{} | ||
| 15 | } | ||
| 16 | cmd.SysProcAttr.Setpgid = true | ||
| 17 | } | ||
| 18 | |||
| 19 | func killTree(cmd *exec.Cmd) { | ||
| 20 | if cmd.Process == nil { | ||
| 21 | return | ||
| 22 | } | ||
| 23 | if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { | ||
| 24 | cmd.Process.Kill() | ||
| 25 | } | ||
| 26 | } | ||
internal/gitutil/transport_test.go added +33
| @@ -0,0 +1,33 @@ | |||
| 1 | package gitutil | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "io" | ||
| 5 | "os/exec" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // Closing cancel kills the transport; it does not wait for the client | ||
| 12 | // to hang up. Stdin ends only after the kill, as a cut connection's | ||
| 13 | // does, so a clean exit here would mean the kill never happened. | ||
| 14 | func TestTransportCancelKillsGit(t *testing.T) { | ||
| 15 | dir := t.TempDir() | ||
| 16 | if out, err := exec.Command("git", "init", "-q", "--bare", dir).CombinedOutput(); err != nil { | ||
| 17 | t.Fatalf("git init: %v\n%s", err, out) | ||
| 18 | } | ||
| 19 | in, w := io.Pipe() | ||
| 20 | cancel := make(chan struct{}) | ||
| 21 | errc := make(chan error, 1) | ||
| 22 | go func() { errc <- Transport("git-upload-pack", dir, in, io.Discard, io.Discard, nil, 0, cancel) }() | ||
| 23 | close(cancel) | ||
| 24 | time.AfterFunc(500*time.Millisecond, func() { w.Close() }) | ||
| 25 | select { | ||
| 26 | case err := <-errc: | ||
| 27 | if err == nil || !strings.Contains(err.Error(), "killed") { | ||
| 28 | t.Fatalf("Transport returned %v, want the process killed", err) | ||
| 29 | } | ||
| 30 | case <-time.After(5 * time.Second): | ||
| 31 | t.Fatal("Transport did not return after cancel") | ||
| 32 | } | ||
| 33 | } | ||
internal/sshd/revoke_test.go added +112
| @@ -0,0 +1,112 @@ | |||
| 1 | package sshd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "errors" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "golang.org/x/crypto/ssh" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // execStatus runs cmd on a new session and returns its exit status and | ||
| 14 | // stderr; -1 when the session could not run. | ||
| 15 | func execStatus(client *ssh.Client, cmd string) (int, string) { | ||
| 16 | sess, err := client.NewSession() | ||
| 17 | if err != nil { | ||
| 18 | return -1, err.Error() | ||
| 19 | } | ||
| 20 | defer sess.Close() | ||
| 21 | var stderr bytes.Buffer | ||
| 22 | sess.Stderr = &stderr | ||
| 23 | err = sess.Run(cmd) | ||
| 24 | var exit *ssh.ExitError | ||
| 25 | switch { | ||
| 26 | case err == nil: | ||
| 27 | return 0, stderr.String() | ||
| 28 | case errors.As(err, &exit): | ||
| 29 | return exit.ExitStatus(), stderr.String() | ||
| 30 | } | ||
| 31 | return -1, err.Error() | ||
| 32 | } | ||
| 33 | |||
| 34 | // waitClosed fails unless the server closes the client's connection | ||
| 35 | // within five seconds. | ||
| 36 | func waitClosed(t *testing.T, client *ssh.Client) { | ||
| 37 | t.Helper() | ||
| 38 | done := make(chan struct{}) | ||
| 39 | go func() { client.Wait(); close(done) }() | ||
| 40 | select { | ||
| 41 | case <-done: | ||
| 42 | case <-time.After(5 * time.Second): | ||
| 43 | t.Fatal("the connection stayed open") | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | // Each exec reads the key again. The rows change behind the store's | ||
| 48 | // back here, so no revocation is announced and the connection stays up: | ||
| 49 | // what refuses the command is the per-exec check alone. | ||
| 50 | func TestExecRevalidatesKey(t *testing.T) { | ||
| 51 | ts := newTestServer(t) | ||
| 52 | if code, errOut := execStatus(ts.client, "whoami"); code != 0 { | ||
| 53 | t.Fatalf("whoami: %d %s", code, errOut) | ||
| 54 | } | ||
| 55 | if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET scope = 'git' WHERE id = ?", ts.keyID); err != nil { | ||
| 56 | t.Fatal(err) | ||
| 57 | } | ||
| 58 | if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "does not allow control commands") { | ||
| 59 | t.Fatalf("whoami after re-scope: %d %q", code, errOut) | ||
| 60 | } | ||
| 61 | if _, err := ts.st.DB.Exec("DELETE FROM ssh_keys WHERE id = ?", ts.keyID); err != nil { | ||
| 62 | t.Fatal(err) | ||
| 63 | } | ||
| 64 | if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "no longer registered") { | ||
| 65 | t.Fatalf("whoami after delete: %d %q", code, errOut) | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | // Removing the key cuts the connection, ending a command running on it. | ||
| 70 | func TestRemoveKeyCutsConnection(t *testing.T) { | ||
| 71 | ts := newTestServer(t) | ||
| 72 | withBuild(t, ts) | ||
| 73 | var stderr bytes.Buffer | ||
| 74 | sess := startFollow(t, ts.client, &stderr) | ||
| 75 | if err := ts.st.RemoveSSHKey(ts.uid, ts.fp); err != nil { | ||
| 76 | t.Fatal(err) | ||
| 77 | } | ||
| 78 | waited := make(chan error, 1) | ||
| 79 | go func() { waited <- sess.Wait() }() | ||
| 80 | select { | ||
| 81 | case err := <-waited: | ||
| 82 | if err == nil { | ||
| 83 | t.Fatal("the follow exited cleanly after its key was removed") | ||
| 84 | } | ||
| 85 | case <-time.After(5 * time.Second): | ||
| 86 | t.Fatal("the follow outlived its key") | ||
| 87 | } | ||
| 88 | waitClosed(t, ts.client) | ||
| 89 | } | ||
| 90 | |||
| 91 | func TestDisableCutsConnection(t *testing.T) { | ||
| 92 | ts := newTestServer(t) | ||
| 93 | if err := ts.st.SetUserDisabled(ts.uid, true); err != nil { | ||
| 94 | t.Fatal(err) | ||
| 95 | } | ||
| 96 | waitClosed(t, ts.client) | ||
| 97 | } | ||
| 98 | |||
| 99 | // A revocation made by another process is not announced here; the | ||
| 100 | // sweep finds it. A live key survives the sweep. | ||
| 101 | func TestSweepCutsOutOfProcessRevocation(t *testing.T) { | ||
| 102 | ts := newTestServer(t) | ||
| 103 | ts.srv.sweepOnce() | ||
| 104 | if code, errOut := execStatus(ts.client, "whoami"); code != 0 { | ||
| 105 | t.Fatalf("the sweep cut a live key: %d %s", code, errOut) | ||
| 106 | } | ||
| 107 | if _, err := ts.st.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", ts.uid); err != nil { | ||
| 108 | t.Fatal(err) | ||
| 109 | } | ||
| 110 | ts.srv.sweepOnce() | ||
| 111 | waitClosed(t, ts.client) | ||
| 112 | } | ||
internal/sshd/sshd.go +121 −17
| @@ -12,9 +12,11 @@ import ( | |||
| 12 | "fmt" | 12 | "fmt" |
| 13 | "io" | 13 | "io" |
| 14 | "log/slog" | 14 | "log/slog" |
| 15 | "maps" | ||
| 15 | "net" | 16 | "net" |
| 16 | "os" | 17 | "os" |
| 17 | "path/filepath" | 18 | "path/filepath" |
| 19 | "slices" | ||
| 18 | "strconv" | 20 | "strconv" |
| 19 | "sync" | 21 | "sync" |
| 20 | "sync/atomic" | 22 | "sync/atomic" |
| @@ -50,6 +52,19 @@ type Server struct { | |||
| 50 | type conn struct { | 52 | type conn struct { |
| 51 | net net.Conn | 53 | net net.Conn |
| 52 | active atomic.Int32 | 54 | active atomic.Int32 |
| 55 | // keyID and userID are the key that authenticated the connection and | ||
| 56 | // its account: 0 before the handshake and for an unregistered key. | ||
| 57 | // Guarded by Server.mu. | ||
| 58 | keyID, userID int64 | ||
| 59 | revoked chan struct{} // closed by cut | ||
| 60 | cutOnce sync.Once | ||
| 61 | } | ||
| 62 | |||
| 63 | // cut ends the connection because its key was revoked: a git transport | ||
| 64 | // on it is killed, and every other command loses its channel. | ||
| 65 | func (c *conn) cut() { | ||
| 66 | c.cutOnce.Do(func() { close(c.revoked) }) | ||
| 67 | c.net.Close() | ||
| 53 | } | 68 | } |
| 54 | 69 | ||
| 55 | func New(cfg config.Config, st *store.Store) (*Server, error) { | 70 | func New(cfg config.Config, st *store.Store) (*Server, error) { |
| @@ -67,6 +82,7 @@ func New(cfg config.Config, st *store.Store) (*Server, error) { | |||
| 67 | sc.AddHostKey(sg) | 82 | sc.AddHostKey(sg) |
| 68 | } | 83 | } |
| 69 | s.sshCfg = sc | 84 | s.sshCfg = sc |
| 85 | st.OnRevoke(s.revoke) | ||
| 70 | return s, nil | 86 | return s, nil |
| 71 | } | 87 | } |
| 72 | 88 | ||
| @@ -150,19 +166,20 @@ func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Pe | |||
| 150 | return &ssh.Permissions{Extensions: map[string]string{ | 166 | return &ssh.Permissions{Extensions: map[string]string{ |
| 151 | "user-id": strconv.FormatInt(key.UserID, 10), | 167 | "user-id": strconv.FormatInt(key.UserID, 10), |
| 152 | "key-id": strconv.FormatInt(key.ID, 10), | 168 | "key-id": strconv.FormatInt(key.ID, 10), |
| 153 | "key-fp": fp, | ||
| 154 | "scope": key.Scope, | ||
| 155 | }}, nil | 169 | }}, nil |
| 156 | } | 170 | } |
| 157 | 171 | ||
| 158 | // Serve accepts connections on ln until it is closed. | 172 | // Serve accepts connections on ln until it is closed. |
| 159 | func (s *Server) Serve(ln net.Listener) error { | 173 | func (s *Server) Serve(ln net.Listener) error { |
| 174 | served := make(chan struct{}) | ||
| 175 | defer close(served) | ||
| 176 | go s.sweep(served) | ||
| 160 | for { | 177 | for { |
| 161 | nc, err := ln.Accept() | 178 | nc, err := ln.Accept() |
| 162 | if err != nil { | 179 | if err != nil { |
| 163 | return err | 180 | return err |
| 164 | } | 181 | } |
| 165 | c := &conn{net: nc} | 182 | c := &conn{net: nc, revoked: make(chan struct{})} |
| 166 | s.mu.Lock() | 183 | s.mu.Lock() |
| 167 | s.conns[c] = struct{}{} | 184 | s.conns[c] = struct{}{} |
| 168 | s.mu.Unlock() | 185 | s.mu.Unlock() |
| @@ -179,6 +196,76 @@ func (s *Server) Serve(ln net.Listener) error { | |||
| 179 | } | 196 | } |
| 180 | } | 197 | } |
| 181 | 198 | ||
| 199 | // revoke closes the connections opened by the keys r names. | ||
| 200 | func (s *Server) revoke(r store.Revoked) { | ||
| 201 | var cut []*conn | ||
| 202 | s.mu.Lock() | ||
| 203 | for c := range s.conns { | ||
| 204 | if c.keyID == 0 { | ||
| 205 | continue | ||
| 206 | } | ||
| 207 | if (r.UserID != 0 && c.userID == r.UserID) || slices.Contains(r.KeyIDs, c.keyID) { | ||
| 208 | cut = append(cut, c) | ||
| 209 | } | ||
| 210 | } | ||
| 211 | s.mu.Unlock() | ||
| 212 | for _, c := range cut { | ||
| 213 | c.cut() | ||
| 214 | } | ||
| 215 | } | ||
| 216 | |||
| 217 | // sweepInterval bounds how long a revocation this process was not told | ||
| 218 | // about (gitbayd admin on the host) leaves a connection open. | ||
| 219 | const sweepInterval = 15 * time.Second | ||
| 220 | |||
| 221 | func (s *Server) sweep(served <-chan struct{}) { | ||
| 222 | t := time.NewTicker(sweepInterval) | ||
| 223 | defer t.Stop() | ||
| 224 | for { | ||
| 225 | select { | ||
| 226 | case <-t.C: | ||
| 227 | s.sweepOnce() | ||
| 228 | case <-served: | ||
| 229 | return | ||
| 230 | case <-s.stopping: | ||
| 231 | return | ||
| 232 | } | ||
| 233 | } | ||
| 234 | } | ||
| 235 | |||
| 236 | // sweepOnce cuts every connection whose key is no longer live. Only | ||
| 237 | // connections whose key was asked about are judged: one that | ||
| 238 | // authenticated while the query ran waits for the next sweep. | ||
| 239 | func (s *Server) sweepOnce() { | ||
| 240 | asked := map[int64]bool{} | ||
| 241 | s.mu.Lock() | ||
| 242 | for c := range s.conns { | ||
| 243 | if c.keyID != 0 { | ||
| 244 | asked[c.keyID] = true | ||
| 245 | } | ||
| 246 | } | ||
| 247 | s.mu.Unlock() | ||
| 248 | if len(asked) == 0 { | ||
| 249 | return | ||
| 250 | } | ||
| 251 | live, err := s.st.LiveSSHKeys(slices.Collect(maps.Keys(asked))) | ||
| 252 | if err != nil { | ||
| 253 | slog.Error("ssh sweep: key lookup", "err", err) | ||
| 254 | return | ||
| 255 | } | ||
| 256 | var cut []*conn | ||
| 257 | s.mu.Lock() | ||
| 258 | for c := range s.conns { | ||
| 259 | if asked[c.keyID] && !live[c.keyID] { | ||
| 260 | cut = append(cut, c) | ||
| 261 | } | ||
| 262 | } | ||
| 263 | s.mu.Unlock() | ||
| 264 | for _, c := range cut { | ||
| 265 | c.cut() | ||
| 266 | } | ||
| 267 | } | ||
| 268 | |||
| 182 | // Stop ends the commands that run until something happens (build log | 269 | // Stop ends the commands that run until something happens (build log |
| 183 | // --follow), so a shutdown drain waits only for work that finishes. It | 270 | // --follow), so a shutdown drain waits only for work that finishes. It |
| 184 | // does not close connections; Shutdown does. | 271 | // does not close connections; Shutdown does. |
| @@ -218,6 +305,11 @@ func (s *Server) handleConn(c *conn) { | |||
| 218 | return | 305 | return |
| 219 | } | 306 | } |
| 220 | defer sconn.Close() | 307 | defer sconn.Close() |
| 308 | ext := sconn.Permissions.Extensions | ||
| 309 | s.mu.Lock() | ||
| 310 | c.keyID, _ = strconv.ParseInt(ext["key-id"], 10, 64) | ||
| 311 | c.userID, _ = strconv.ParseInt(ext["user-id"], 10, 64) | ||
| 312 | s.mu.Unlock() | ||
| 221 | go ssh.DiscardRequests(reqs) | 313 | go ssh.DiscardRequests(reqs) |
| 222 | 314 | ||
| 223 | for newCh := range chans { | 315 | for newCh := range chans { |
| @@ -232,12 +324,12 @@ func (s *Server) handleConn(c *conn) { | |||
| 232 | c.active.Add(1) | 324 | c.active.Add(1) |
| 233 | go func() { | 325 | go func() { |
| 234 | defer c.active.Add(-1) | 326 | defer c.active.Add(-1) |
| 235 | s.handleSession(sconn, ch, chReqs) | 327 | s.handleSession(c, sconn, ch, chReqs) |
| 236 | }() | 328 | }() |
| 237 | } | 329 | } |
| 238 | } | 330 | } |
| 239 | 331 | ||
| 240 | func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) { | 332 | func (s *Server) handleSession(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) { |
| 241 | defer ch.Close() | 333 | defer ch.Close() |
| 242 | var term control.Term | 334 | var term control.Term |
| 243 | for req := range reqs { | 335 | for req := range reqs { |
| @@ -268,7 +360,7 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha | |||
| 268 | } | 360 | } |
| 269 | close(done) | 361 | close(done) |
| 270 | }() | 362 | }() |
| 271 | code := s.runExec(sconn, ch, term, payload.Command, done) | 363 | code := s.runExec(c, sconn, ch, term, payload.Command, done) |
| 272 | sendExit(ch, code) | 364 | sendExit(ch, code) |
| 273 | return | 365 | return |
| 274 | case "shell": | 366 | case "shell": |
| @@ -296,20 +388,32 @@ func sendExit(ch ssh.Channel, code int) { | |||
| 296 | ch.SendRequest("exit-status", false, ssh.Marshal(&msg)) | 388 | ch.SendRequest("exit-status", false, ssh.Marshal(&msg)) |
| 297 | } | 389 | } |
| 298 | 390 | ||
| 299 | func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int { | 391 | func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int { |
| 300 | ext := sconn.Permissions.Extensions | 392 | ext := sconn.Permissions.Extensions |
| 301 | if blob := ext["anon-key"]; blob != "" { | 393 | if blob := ext["anon-key"]; blob != "" { |
| 302 | return s.runAnonymous(ch, blob, cmdline) | 394 | return s.runAnonymous(ch, blob, cmdline) |
| 303 | } | 395 | } |
| 304 | userID, _ := strconv.ParseInt(ext["user-id"], 10, 64) | 396 | userID, _ := strconv.ParseInt(ext["user-id"], 10, 64) |
| 305 | keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64) | 397 | keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64) |
| 398 | // A connection outlives its commands, so the key is read again for | ||
| 399 | // each one: what it may do is what it may do now (#256). | ||
| 400 | key, err := s.st.SSHKeyByID(keyID) | ||
| 401 | if errors.Is(err, store.ErrNotFound) || (err == nil && key.UserID != userID) { | ||
| 402 | fmt.Fprintln(ch.Stderr(), "this key is no longer registered") | ||
| 403 | return protocol.ExitDenied | ||
| 404 | } | ||
| 405 | if err != nil { | ||
| 406 | slog.Error("ssh exec: key lookup", "err", err) | ||
| 407 | fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable") | ||
| 408 | return protocol.ExitFailure | ||
| 409 | } | ||
| 306 | user, err := s.st.UserByID(userID) | 410 | user, err := s.st.UserByID(userID) |
| 307 | if err != nil { | 411 | if err != nil { |
| 308 | fmt.Fprintln(ch.Stderr(), "account no longer exists") | 412 | fmt.Fprintln(ch.Stderr(), "account no longer exists") |
| 309 | return protocol.ExitDenied | 413 | return protocol.ExitDenied |
| 310 | } | 414 | } |
| 311 | _ = s.st.TouchSSHKey(keyID) | 415 | _ = s.st.TouchSSHKey(keyID) |
| 312 | return Exec(s.cfg, s.st, user, ext["scope"], ext["key-fp"], term, cmdline, ch, ch, ch.Stderr(), done, s.stopping) | 416 | return Exec(s.cfg, s.st, user, key, term, cmdline, ch, ch, ch.Stderr(), done, s.stopping, c.revoked) |
| 313 | } | 417 | } |
| 314 | 418 | ||
| 315 | // runAnonymous handles a session from an unregistered key: the register | 419 | // runAnonymous handles a session from an unregistered key: the register |
| @@ -338,9 +442,9 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int { | |||
| 338 | 442 | ||
| 339 | // Exec runs one SSH exec command line for an authenticated key. It is the | 443 | // Exec runs one SSH exec command line for an authenticated key. It is the |
| 340 | // single dispatch path shared by the embedded listener and the system-sshd | 444 | // single dispatch path shared by the embedded listener and the system-sshd |
| 341 | // forced command (gitbayd shell). | 445 | // forced command (gitbayd shell). Closing revoked kills a git transport. |
| 342 | func Exec(cfg config.Config, st *store.Store, user store.User, scope, source string, term control.Term, cmdline string, | 446 | func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string, |
| 343 | stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int { | 447 | stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int { |
| 344 | if user.Disabled { | 448 | if user.Disabled { |
| 345 | fmt.Fprintln(stderr, "this account is disabled; contact the instance admin") | 449 | fmt.Fprintln(stderr, "this account is disabled; contact the instance admin") |
| 346 | return protocol.ExitDenied | 450 | return protocol.ExitDenied |
| @@ -357,7 +461,7 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, source str | |||
| 357 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") | 461 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") |
| 358 | return protocol.ExitDenied | 462 | return protocol.ExitDenied |
| 359 | } | 463 | } |
| 360 | return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr) | 464 | return runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked) |
| 361 | case "git-lfs-authenticate": | 465 | case "git-lfs-authenticate": |
| 362 | // Part of the git transport, not the control plane: usable by | 466 | // Part of the git transport, not the control plane: usable by |
| 363 | // git-scoped and deploy keys, with the transports' access rules. | 467 | // git-scoped and deploy keys, with the transports' access rules. |
| @@ -365,13 +469,13 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, source str | |||
| 365 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") | 469 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") |
| 366 | return protocol.ExitDenied | 470 | return protocol.ExitDenied |
| 367 | } | 471 | } |
| 368 | return runLFSAuthenticate(cfg, st, user, scope, argv, stdout, stderr) | 472 | return runLFSAuthenticate(cfg, st, user, key.Scope, argv, stdout, stderr) |
| 369 | } | 473 | } |
| 370 | } | 474 | } |
| 371 | ctx := &control.Ctx{ | 475 | ctx := &control.Ctx{ |
| 372 | User: user, | 476 | User: user, |
| 373 | Scope: scope, | 477 | Scope: key.Scope, |
| 374 | Source: source, | 478 | Source: key.Fingerprint, |
| 375 | Term: term, | 479 | Term: term, |
| 376 | Store: st, | 480 | Store: st, |
| 377 | Cfg: cfg, | 481 | Cfg: cfg, |
| @@ -386,7 +490,7 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, source str | |||
| 386 | 490 | ||
| 387 | // runGit streams a git transport service after access checks. | 491 | // runGit streams a git transport service after access checks. |
| 388 | func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string, | 492 | func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string, |
| 389 | stdin io.Reader, stdout, stderr io.Writer) int { | 493 | stdin io.Reader, stdout, stderr io.Writer, revoked <-chan struct{}) int { |
| 390 | service := argv[0] | 494 | service := argv[0] |
| 391 | if len(argv) != 2 { | 495 | if len(argv) != 2 { |
| 392 | fmt.Fprintf(stderr, "usage: %s <path>\n", service) | 496 | fmt.Fprintf(stderr, "usage: %s <path>\n", service) |
| @@ -461,7 +565,7 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a | |||
| 461 | } | 565 | } |
| 462 | } | 566 | } |
| 463 | } | 567 | } |
| 464 | if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack); err != nil { | 568 | if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil { |
| 465 | return protocol.ExitFailure | 569 | return protocol.ExitFailure |
| 466 | } | 570 | } |
| 467 | return protocol.ExitOK | 571 | return protocol.ExitOK |
internal/sshd/sshd_test.go +46 −17
| @@ -18,10 +18,18 @@ import ( | |||
| 18 | "gitbay.org/gitbay/internal/store" | 18 | "gitbay.org/gitbay/internal/store" |
| 19 | ) | 19 | ) |
| 20 | 20 | ||
| 21 | // followServer starts an embedded server holding alice, her public repo | 21 | // testServer is an embedded server over a fresh store holding alice |
| 22 | // alice/app and a queued build 1 whose log has one line, and returns it | 22 | // with one full-scope key, and a client connected with that key. |
| 23 | // with a client connected as alice. | 23 | type testServer struct { |
| 24 | func followServer(t *testing.T) (*Server, *ssh.Client) { | 24 | srv *Server |
| 25 | st *store.Store | ||
| 26 | client *ssh.Client | ||
| 27 | uid int64 | ||
| 28 | keyID int64 | ||
| 29 | fp string | ||
| 30 | } | ||
| 31 | |||
| 32 | func newTestServer(t *testing.T) testServer { | ||
| 25 | t.Helper() | 33 | t.Helper() |
| 26 | root := t.TempDir() | 34 | root := t.TempDir() |
| 27 | st, err := store.Open(filepath.Join(root, "gitbay.db")) | 35 | st, err := store.Open(filepath.Join(root, "gitbay.db")) |
| @@ -36,17 +44,6 @@ func followServer(t *testing.T) (*Server, *ssh.Client) { | |||
| 36 | if err != nil { | 44 | if err != nil { |
| 37 | t.Fatal(err) | 45 | t.Fatal(err) |
| 38 | } | 46 | } |
| 39 | repoID, err := st.CreateRepo("user", uid, "app", "public") | ||
| 40 | if err != nil { | ||
| 41 | t.Fatal(err) | ||
| 42 | } | ||
| 43 | id, err := st.CreateBuild(repoID, "unit", "abc", "main", `["true"]`, "", "", true) | ||
| 44 | if err != nil { | ||
| 45 | t.Fatal(err) | ||
| 46 | } | ||
| 47 | if err := st.AppendBuildLog(id, []byte("queued\n")); err != nil { | ||
| 48 | t.Fatal(err) | ||
| 49 | } | ||
| 50 | _, priv, err := ed25519.GenerateKey(rand.Reader) | 47 | _, priv, err := ed25519.GenerateKey(rand.Reader) |
| 51 | if err != nil { | 48 | if err != nil { |
| 52 | t.Fatal(err) | 49 | t.Fatal(err) |
| @@ -56,7 +53,12 @@ func followServer(t *testing.T) (*Server, *ssh.Client) { | |||
| 56 | t.Fatal(err) | 53 | t.Fatal(err) |
| 57 | } | 54 | } |
| 58 | pub := signer.PublicKey() | 55 | pub := signer.PublicKey() |
| 59 | if err := st.AddSSHKey(uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full", "test"); err != nil { | 56 | fp := ssh.FingerprintSHA256(pub) |
| 57 | if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full", "test"); err != nil { | ||
| 58 | t.Fatal(err) | ||
| 59 | } | ||
| 60 | key, err := st.SSHKeyByFingerprint(fp) | ||
| 61 | if err != nil { | ||
| 60 | t.Fatal(err) | 62 | t.Fatal(err) |
| 61 | } | 63 | } |
| 62 | 64 | ||
| @@ -83,7 +85,34 @@ func followServer(t *testing.T) (*Server, *ssh.Client) { | |||
| 83 | t.Fatal(err) | 85 | t.Fatal(err) |
| 84 | } | 86 | } |
| 85 | t.Cleanup(func() { client.Close() }) | 87 | t.Cleanup(func() { client.Close() }) |
| 86 | return srv, client | 88 | return testServer{srv: srv, st: st, client: client, uid: uid, keyID: key.ID, fp: fp} |
| 89 | } | ||
| 90 | |||
| 91 | // withBuild gives alice the public repo alice/app and a queued build 1 | ||
| 92 | // whose log has one line. | ||
| 93 | func withBuild(t *testing.T, ts testServer) { | ||
| 94 | t.Helper() | ||
| 95 | repoID, err := ts.st.CreateRepo("user", ts.uid, "app", "public") | ||
| 96 | if err != nil { | ||
| 97 | t.Fatal(err) | ||
| 98 | } | ||
| 99 | id, err := ts.st.CreateBuild(repoID, "unit", "abc", "main", `["true"]`, "", "", true) | ||
| 100 | if err != nil { | ||
| 101 | t.Fatal(err) | ||
| 102 | } | ||
| 103 | if err := ts.st.AppendBuildLog(id, []byte("queued\n")); err != nil { | ||
| 104 | t.Fatal(err) | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | // followServer starts an embedded server holding alice, her public repo | ||
| 109 | // alice/app and a queued build 1 whose log has one line, and returns it | ||
| 110 | // with a client connected as alice. | ||
| 111 | func followServer(t *testing.T) (*Server, *ssh.Client) { | ||
| 112 | t.Helper() | ||
| 113 | ts := newTestServer(t) | ||
| 114 | withBuild(t, ts) | ||
| 115 | return ts.srv, ts.client | ||
| 87 | } | 116 | } |
| 88 | 117 | ||
| 89 | // startFollow runs build log --follow on a new session and returns once | 118 | // startFollow runs build log --follow on a new session and returns once |
internal/store/revoke.go added +60
| @@ -0,0 +1,60 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "slices" | ||
| 5 | "strings" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // Revoked names SSH keys that stopped being valid: by id, or every key | ||
| 9 | // of an account. The SSH listener closes the connections they opened. | ||
| 10 | type Revoked struct { | ||
| 11 | KeyIDs []int64 | ||
| 12 | UserID int64 // every key of this account; 0 for none | ||
| 13 | } | ||
| 14 | |||
| 15 | // OnRevoke registers f to run after each revocation this process | ||
| 16 | // commits. Revocations committed by another process (gitbayd admin on | ||
| 17 | // the host) are not announced; the listener's sweep finds those. | ||
| 18 | func (s *Store) OnRevoke(f func(Revoked)) { | ||
| 19 | s.revokeMu.Lock() | ||
| 20 | defer s.revokeMu.Unlock() | ||
| 21 | s.onRevoke = append(s.onRevoke, f) | ||
| 22 | } | ||
| 23 | |||
| 24 | // announce runs the subscribers. Call it after the commit, outside any | ||
| 25 | // transaction. | ||
| 26 | func (s *Store) announce(r Revoked) { | ||
| 27 | s.revokeMu.Lock() | ||
| 28 | fs := slices.Clone(s.onRevoke) | ||
| 29 | s.revokeMu.Unlock() | ||
| 30 | for _, f := range fs { | ||
| 31 | f(r) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | // LiveSSHKeys reports which of ids still name a registered key on an | ||
| 36 | // account that is not disabled. | ||
| 37 | func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) { | ||
| 38 | live := map[int64]bool{} | ||
| 39 | if len(ids) == 0 { | ||
| 40 | return live, nil | ||
| 41 | } | ||
| 42 | args := make([]any, len(ids)) | ||
| 43 | for i, id := range ids { | ||
| 44 | args[i] = id | ||
| 45 | } | ||
| 46 | rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id | ||
| 47 | WHERE u.disabled = 0 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...) | ||
| 48 | if err != nil { | ||
| 49 | return nil, err | ||
| 50 | } | ||
| 51 | defer rows.Close() | ||
| 52 | for rows.Next() { | ||
| 53 | var id int64 | ||
| 54 | if err := rows.Scan(&id); err != nil { | ||
| 55 | return nil, err | ||
| 56 | } | ||
| 57 | live[id] = true | ||
| 58 | } | ||
| 59 | return live, rows.Err() | ||
| 60 | } | ||
internal/store/revoke_test.go added +107
| @@ -0,0 +1,107 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "slices" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | func revokeFixture(t *testing.T) (*Store, int64, *[]Revoked) { | ||
| 9 | t.Helper() | ||
| 10 | s := open(t) | ||
| 11 | if err := s.MigrateUp(); err != nil { | ||
| 12 | t.Fatal(err) | ||
| 13 | } | ||
| 14 | uid, err := s.CreateUser("alice", false) | ||
| 15 | if err != nil { | ||
| 16 | t.Fatal(err) | ||
| 17 | } | ||
| 18 | var got []Revoked | ||
| 19 | s.OnRevoke(func(r Revoked) { got = append(got, r) }) | ||
| 20 | return s, uid, &got | ||
| 21 | } | ||
| 22 | |||
| 23 | func keyID(t *testing.T, s *Store, fp string) int64 { | ||
| 24 | t.Helper() | ||
| 25 | k, err := s.SSHKeyByFingerprint(fp) | ||
| 26 | if err != nil { | ||
| 27 | t.Fatal(err) | ||
| 28 | } | ||
| 29 | return k.ID | ||
| 30 | } | ||
| 31 | |||
| 32 | func TestRemovalsAnnounceTheirKeys(t *testing.T) { | ||
| 33 | s, uid, got := revokeFixture(t) | ||
| 34 | if err := s.AddSSHKey(uid, "SHA256:a", "ssh-ed25519", []byte("a"), "full", ""); err != nil { | ||
| 35 | t.Fatal(err) | ||
| 36 | } | ||
| 37 | if err := s.AddSSHKey(uid, "SHA256:d", "ssh-ed25519", []byte("d"), "deploy:7:ro", ""); err != nil { | ||
| 38 | t.Fatal(err) | ||
| 39 | } | ||
| 40 | a, d := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:d") | ||
| 41 | |||
| 42 | if err := s.RemoveSSHKey(uid, "SHA256:a"); err != nil { | ||
| 43 | t.Fatal(err) | ||
| 44 | } | ||
| 45 | if err := s.RemoveDeployKey(7, "SHA256:d"); err != nil { | ||
| 46 | t.Fatal(err) | ||
| 47 | } | ||
| 48 | if err := s.SetUserDisabled(uid, true); err != nil { | ||
| 49 | t.Fatal(err) | ||
| 50 | } | ||
| 51 | if err := s.SetUserDisabled(uid, false); err != nil { | ||
| 52 | t.Fatal(err) | ||
| 53 | } | ||
| 54 | want := []Revoked{{KeyIDs: []int64{a}}, {KeyIDs: []int64{d}}, {UserID: uid}} | ||
| 55 | if !slices.EqualFunc(*got, want, func(x, y Revoked) bool { | ||
| 56 | return slices.Equal(x.KeyIDs, y.KeyIDs) && x.UserID == y.UserID | ||
| 57 | }) { | ||
| 58 | t.Fatalf("announced %+v, want %+v (enabling announces nothing)", *got, want) | ||
| 59 | } | ||
| 60 | // A removal that found nothing announces nothing. | ||
| 61 | if err := s.RemoveSSHKey(uid, "SHA256:a"); err != ErrNotFound { | ||
| 62 | t.Fatalf("second remove: %v", err) | ||
| 63 | } | ||
| 64 | if len(*got) != 3 { | ||
| 65 | t.Fatalf("a miss was announced: %+v", *got) | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | func TestDeleteUserAnnounces(t *testing.T) { | ||
| 70 | s, uid, got := revokeFixture(t) | ||
| 71 | if err := s.DeleteUser(uid); err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | if len(*got) != 1 || (*got)[0].UserID != uid { | ||
| 75 | t.Fatalf("announced %+v", *got) | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | func TestLiveSSHKeys(t *testing.T) { | ||
| 80 | s, uid, _ := revokeFixture(t) | ||
| 81 | bob, err := s.CreateUser("bob", false) | ||
| 82 | if err != nil { | ||
| 83 | t.Fatal(err) | ||
| 84 | } | ||
| 85 | for _, k := range []struct { | ||
| 86 | uid int64 | ||
| 87 | fp string | ||
| 88 | }{{uid, "SHA256:a"}, {bob, "SHA256:b"}} { | ||
| 89 | if err := s.AddSSHKey(k.uid, k.fp, "ssh-ed25519", []byte(k.fp), "full", ""); err != nil { | ||
| 90 | t.Fatal(err) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | a, b := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:b") | ||
| 94 | if _, err := s.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", bob); err != nil { | ||
| 95 | t.Fatal(err) | ||
| 96 | } | ||
| 97 | live, err := s.LiveSSHKeys([]int64{a, b, 999}) | ||
| 98 | if err != nil { | ||
| 99 | t.Fatal(err) | ||
| 100 | } | ||
| 101 | if !live[a] || live[b] || live[999] { | ||
| 102 | t.Fatalf("live = %v; want only %d", live, a) | ||
| 103 | } | ||
| 104 | if live, err := s.LiveSSHKeys(nil); err != nil || len(live) != 0 { | ||
| 105 | t.Fatalf("no ids: %v %v", live, err) | ||
| 106 | } | ||
| 107 | } | ||
internal/store/store.go +4
| @@ -27,6 +27,10 @@ type Store struct { | |||
| 27 | // by the next change to that build's row (BuildLogWait). | 27 | // by the next change to that build's row (BuildLogWait). |
| 28 | logMu sync.Mutex | 28 | logMu sync.Mutex |
| 29 | logWait map[int64]chan struct{} | 29 | logWait map[int64]chan struct{} |
| 30 | |||
| 31 | // onRevoke runs after each key revocation this process commits. | ||
| 32 | revokeMu sync.Mutex | ||
| 33 | onRevoke []func(Revoked) | ||
| 30 | } | 34 | } |
| 31 | 35 | ||
| 32 | // Open opens (creating if needed) the database at path with WAL mode and | 36 | // Open opens (creating if needed) the database at path with WAL mode and |
internal/store/users.go +28 −14
| @@ -95,6 +95,7 @@ func (s *Store) DeleteUser(id int64) error { | |||
| 95 | if n, _ := res.RowsAffected(); n == 0 { | 95 | if n, _ := res.RowsAffected(); n == 0 { |
| 96 | return ErrNotFound | 96 | return ErrNotFound |
| 97 | } | 97 | } |
| 98 | s.announce(Revoked{UserID: id}) | ||
| 98 | return nil | 99 | return nil |
| 99 | } | 100 | } |
| 100 | 101 | ||
| @@ -171,7 +172,8 @@ func (s *Store) ListEmails(userID int64) ([]Email, error) { | |||
| 171 | // SetUserDisabled suspends or restores an account. Disabling drops every | 172 | // SetUserDisabled suspends or restores an account. Disabling drops every |
| 172 | // credential that would grant a session on its own — web sessions, API | 173 | // credential that would grant a session on its own — web sessions, API |
| 173 | // tokens, unclaimed login links — and leaves the SSH keys registered but | 174 | // tokens, unclaimed login links — and leaves the SSH keys registered but |
| 174 | // refused at every entry point until re-enabled. | 175 | // refused at every entry point until re-enabled; connections they opened |
| 176 | // are closed. | ||
| 175 | func (s *Store) SetUserDisabled(userID int64, disabled bool) error { | 177 | func (s *Store) SetUserDisabled(userID int64, disabled bool) error { |
| 176 | v := 0 | 178 | v := 0 |
| 177 | if disabled { | 179 | if disabled { |
| @@ -192,6 +194,7 @@ func (s *Store) SetUserDisabled(userID int64, disabled bool) error { | |||
| 192 | return err | 194 | return err |
| 193 | } | 195 | } |
| 194 | } | 196 | } |
| 197 | s.announce(Revoked{UserID: userID}) | ||
| 195 | } | 198 | } |
| 196 | return err | 199 | return err |
| 197 | } | 200 | } |
| @@ -288,24 +291,30 @@ func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, s | |||
| 288 | return tx.Commit() | 291 | return tx.Commit() |
| 289 | } | 292 | } |
| 290 | 293 | ||
| 291 | // RemoveSSHKey removes a key owned by userID and bumps the key epoch. | 294 | // RemoveSSHKey removes a key owned by userID, bumps the key epoch, and |
| 295 | // announces the revocation. | ||
| 292 | func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error { | 296 | func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error { |
| 293 | tx, err := s.DB.Begin() | 297 | tx, err := s.DB.Begin() |
| 294 | if err != nil { | 298 | if err != nil { |
| 295 | return err | 299 | return err |
| 296 | } | 300 | } |
| 297 | defer tx.Rollback() | 301 | defer tx.Rollback() |
| 298 | res, err := tx.Exec("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ?", userID, fingerprint) | 302 | var id int64 |
| 303 | err = tx.QueryRow("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ? RETURNING id", userID, fingerprint).Scan(&id) | ||
| 304 | if errors.Is(err, sql.ErrNoRows) { | ||
| 305 | return ErrNotFound | ||
| 306 | } | ||
| 299 | if err != nil { | 307 | if err != nil { |
| 300 | return err | 308 | return err |
| 301 | } | 309 | } |
| 302 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 303 | return ErrNotFound | ||
| 304 | } | ||
| 305 | if err := bumpKeyEpoch(tx); err != nil { | 310 | if err := bumpKeyEpoch(tx); err != nil { |
| 306 | return err | 311 | return err |
| 307 | } | 312 | } |
| 308 | return tx.Commit() | 313 | if err := tx.Commit(); err != nil { |
| 314 | return err | ||
| 315 | } | ||
| 316 | s.announce(Revoked{KeyIDs: []int64{id}}) | ||
| 317 | return nil | ||
| 309 | } | 318 | } |
| 310 | 319 | ||
| 311 | // SetSSHKeyLabel renames a key owned by userID. Labels do not touch the | 320 | // SetSSHKeyLabel renames a key owned by userID. Labels do not touch the |
| @@ -538,17 +547,22 @@ func (s *Store) RemoveDeployKey(repoID int64, fingerprint string) error { | |||
| 538 | return err | 547 | return err |
| 539 | } | 548 | } |
| 540 | defer tx.Rollback() | 549 | defer tx.Rollback() |
| 541 | res, err := tx.Exec( | 550 | var id int64 |
| 542 | "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%'", | 551 | err = tx.QueryRow( |
| 543 | fingerprint, repoID) | 552 | "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%' RETURNING id", |
| 553 | fingerprint, repoID).Scan(&id) | ||
| 554 | if errors.Is(err, sql.ErrNoRows) { | ||
| 555 | return ErrNotFound | ||
| 556 | } | ||
| 544 | if err != nil { | 557 | if err != nil { |
| 545 | return err | 558 | return err |
| 546 | } | 559 | } |
| 547 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 548 | return ErrNotFound | ||
| 549 | } | ||
| 550 | if err := bumpKeyEpoch(tx); err != nil { | 560 | if err := bumpKeyEpoch(tx); err != nil { |
| 551 | return err | 561 | return err |
| 552 | } | 562 | } |
| 553 | return tx.Commit() | 563 | if err := tx.Commit(); err != nil { |
| 564 | return err | ||
| 565 | } | ||
| 566 | s.announce(Revoked{KeyIDs: []int64{id}}) | ||
| 567 | return nil | ||
| 554 | } | 568 | } |