sshd: removing a key closes its connections !480

merged merged by cmc on 2026-09-28 21:48 UTC · krz/gitbay:revoke-closes-connections into main

20 files changed, +748 −61

Layout: unified · split

.gitbay/wiki/Architecture/05-Identity-and-Access.org +2 −2
@@ -15,8 +15,8 @@
1515
1616| Credential | Format and generation | Stored as | Scope | Expiry | Revocation |
1717|--------------------+-------------------------------------------------+----------------------------------+--------------------------------------------+-------------------------------+-------------------------------------|
18| SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | none | =keys remove= (own keys) |
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) |
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); closes its connections |
2020| API token | =gb_= + 32 random bytes hex | SHA-256 hash | =full= or =read= | optional =--ttl= | =token revoke= |
2121| Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 7 days, no sliding renewal | logout, =web sessions revoke= |
2222| 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.
8484| Hide a repository | =admin repo visibility <repo> private= |
8585| Close registration | =registration.mode = "closed"= and restart |
8686| See what happened | =audit= (filter by actor, action, time) |
87
88Open 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.
2525| Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) |
2626| Session lifetime | partial | 7 days absolute, no idle timeout (#276) |
2727| 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=) |
2929| Delegation bounded by the delegating credential | gap | expiring tokens can mint lasting credentials (#257) |
3030
3131** 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.
1111| Issue | Area | Gap | Severity |
1212|-------+------------------+-----------------------------------------------------------------------+----------|
1313| #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 |
1514| #257 | Credentials | An expiring token can create credentials that outlive it; tokens default to full scope | high |
1615| #258 | CI integrity | Any writer can post a =ci/*= status; tree reuse ignores trust and image | high |
1716| #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.
2928| #281 | TLS | No explicit minimum TLS version | low |
3029| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium |
3130
32Decisions already taken on these: #256 closes a removed key's
33connections, running commands included; #257 refuses credential
31Decisions already taken on these: #257 refuses credential
3432creation from expiring tokens, records which token created each
3533credential, and makes =read= the default scope.
3634
.gitbay/wiki/Threat-Model.org +9
@@ -36,6 +36,15 @@ matrix and the open gaps are in the [[file:Architecture/00-Overview.org][Archite
3636
3737- *SSH public key = identity.* The SSH username is ignored; the presented
3838 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.
3948- *Per-instance trust.* Email verification and key registration are local
4049 to an instance and never transfer. Account migration re-registers keys
4150 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
6969=--label= gives one; =keys label= renames a key, and with no text
7070clears the name. Labels are one line of up to 64 bytes.
7171
72Removing a key closes every connection it opened, including the CLI's
73shared one; removing the key the current command runs on ends that
74command's connection too.
75
7276Scopes: =full= (default; git plus every control command), =git= (git
7377transport only — right for automation keys, which then cannot touch
7478issues, 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
44automatically on daemon start; upgrade notes appear per release when
55anything beyond "replace the binary and restart" is needed.
66
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
713* v1.36.0 — 2026-09-23
814
915Terminal output for the CLI (#254).
cmd/gitbayd/system.go +1 −1
@@ -94,7 +94,7 @@ func shellCmd() *cobra.Command {
9494 fmt.Fprintf(os.Stderr, "gitbay control plane: interactive shells are not available.\nTry: ssh <host> help\n")
9595 os.Exit(protocol.ExitUsage)
9696 }
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)
9898 st.Close()
9999 os.Exit(code)
100100 return nil
e2e/revoke_test.go added +155
@@ -0,0 +1,155 @@
1package e2e
2
3import (
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.
17func pkt(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) }
18
19// readPkt reads one pkt-line; a flush reads as "".
20func 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.
38func 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).
50func 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 {
3535// Transport streams one git transport service (upload-pack, receive-pack,
3636// upload-archive). extraEnv entries are appended to the process environment;
3737// hooks read the GITBAY_* variables from it. maxPack caps incoming pack
38// bytes on receive-pack (0 = unlimited).
39func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64) error {
38// bytes on receive-pack (0 = unlimited). Closing cancel kills the service
39// and everything it started; a push killed before its pre-receive hook
40// answers updates no refs. A nil cancel never fires.
41func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error {
4042 var args []string
4143 switch service {
4244 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
5254 cmd.Stdin = stdin
5355 cmd.Stdout = stdout
5456 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
5672}
5773
5874// 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
3package gitutil
4
5import "os/exec"
6
7func ownProcessGroup(cmd *exec.Cmd) {}
8
9func 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
3package gitutil
4
5import (
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.
12func ownProcessGroup(cmd *exec.Cmd) {
13 if cmd.SysProcAttr == nil {
14 cmd.SysProcAttr = &syscall.SysProcAttr{}
15 }
16 cmd.SysProcAttr.Setpgid = true
17}
18
19func 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 @@
1package gitutil
2
3import (
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.
14func 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 @@
1package sshd
2
3import (
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.
15func 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.
36func 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.
50func 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.
70func 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
91func 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.
101func 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 (
1212 "fmt"
1313 "io"
1414 "log/slog"
15 "maps"
1516 "net"
1617 "os"
1718 "path/filepath"
19 "slices"
1820 "strconv"
1921 "sync"
2022 "sync/atomic"
@@ -50,6 +52,19 @@ type Server struct {
5052type conn struct {
5153 net net.Conn
5254 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.
65func (c *conn) cut() {
66 c.cutOnce.Do(func() { close(c.revoked) })
67 c.net.Close()
5368}
5469
5570func New(cfg config.Config, st *store.Store) (*Server, error) {
@@ -67,6 +82,7 @@ func New(cfg config.Config, st *store.Store) (*Server, error) {
6782 sc.AddHostKey(sg)
6883 }
6984 s.sshCfg = sc
85 st.OnRevoke(s.revoke)
7086 return s, nil
7187}
7288
@@ -150,19 +166,20 @@ func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Pe
150166 return &ssh.Permissions{Extensions: map[string]string{
151167 "user-id": strconv.FormatInt(key.UserID, 10),
152168 "key-id": strconv.FormatInt(key.ID, 10),
153 "key-fp": fp,
154 "scope": key.Scope,
155169 }}, nil
156170}
157171
158172// Serve accepts connections on ln until it is closed.
159173func (s *Server) Serve(ln net.Listener) error {
174 served := make(chan struct{})
175 defer close(served)
176 go s.sweep(served)
160177 for {
161178 nc, err := ln.Accept()
162179 if err != nil {
163180 return err
164181 }
165 c := &conn{net: nc}
182 c := &conn{net: nc, revoked: make(chan struct{})}
166183 s.mu.Lock()
167184 s.conns[c] = struct{}{}
168185 s.mu.Unlock()
@@ -179,6 +196,76 @@ func (s *Server) Serve(ln net.Listener) error {
179196 }
180197}
181198
199// revoke closes the connections opened by the keys r names.
200func (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.
219const sweepInterval = 15 * time.Second
220
221func (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.
239func (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
182269// Stop ends the commands that run until something happens (build log
183270// --follow), so a shutdown drain waits only for work that finishes. It
184271// does not close connections; Shutdown does.
@@ -218,6 +305,11 @@ func (s *Server) handleConn(c *conn) {
218305 return
219306 }
220307 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()
221313 go ssh.DiscardRequests(reqs)
222314
223315 for newCh := range chans {
@@ -232,12 +324,12 @@ func (s *Server) handleConn(c *conn) {
232324 c.active.Add(1)
233325 go func() {
234326 defer c.active.Add(-1)
235 s.handleSession(sconn, ch, chReqs)
327 s.handleSession(c, sconn, ch, chReqs)
236328 }()
237329 }
238330}
239331
240func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
332func (s *Server) handleSession(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
241333 defer ch.Close()
242334 var term control.Term
243335 for req := range reqs {
@@ -268,7 +360,7 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha
268360 }
269361 close(done)
270362 }()
271 code := s.runExec(sconn, ch, term, payload.Command, done)
363 code := s.runExec(c, sconn, ch, term, payload.Command, done)
272364 sendExit(ch, code)
273365 return
274366 case "shell":
@@ -296,20 +388,32 @@ func sendExit(ch ssh.Channel, code int) {
296388 ch.SendRequest("exit-status", false, ssh.Marshal(&msg))
297389}
298390
299func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
391func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
300392 ext := sconn.Permissions.Extensions
301393 if blob := ext["anon-key"]; blob != "" {
302394 return s.runAnonymous(ch, blob, cmdline)
303395 }
304396 userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
305397 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 }
306410 user, err := s.st.UserByID(userID)
307411 if err != nil {
308412 fmt.Fprintln(ch.Stderr(), "account no longer exists")
309413 return protocol.ExitDenied
310414 }
311415 _ = 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)
313417}
314418
315419// 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 {
338442
339443// Exec runs one SSH exec command line for an authenticated key. It is the
340444// single dispatch path shared by the embedded listener and the system-sshd
341// forced command (gitbayd shell).
342func Exec(cfg config.Config, st *store.Store, user store.User, scope, source string, term control.Term, cmdline string,
343 stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int {
445// forced command (gitbayd shell). Closing revoked kills a git transport.
446func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string,
447 stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int {
344448 if user.Disabled {
345449 fmt.Fprintln(stderr, "this account is disabled; contact the instance admin")
346450 return protocol.ExitDenied
@@ -357,7 +461,7 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, source str
357461 fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
358462 return protocol.ExitDenied
359463 }
360 return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr)
464 return runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked)
361465 case "git-lfs-authenticate":
362466 // Part of the git transport, not the control plane: usable by
363467 // 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
365469 fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
366470 return protocol.ExitDenied
367471 }
368 return runLFSAuthenticate(cfg, st, user, scope, argv, stdout, stderr)
472 return runLFSAuthenticate(cfg, st, user, key.Scope, argv, stdout, stderr)
369473 }
370474 }
371475 ctx := &control.Ctx{
372476 User: user,
373 Scope: scope,
374 Source: source,
477 Scope: key.Scope,
478 Source: key.Fingerprint,
375479 Term: term,
376480 Store: st,
377481 Cfg: cfg,
@@ -386,7 +490,7 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, source str
386490
387491// runGit streams a git transport service after access checks.
388492func 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 {
390494 service := argv[0]
391495 if len(argv) != 2 {
392496 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
461565 }
462566 }
463567 }
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 {
465569 return protocol.ExitFailure
466570 }
467571 return protocol.ExitOK
internal/sshd/sshd_test.go +46 −17
@@ -18,10 +18,18 @@ import (
1818 "gitbay.org/gitbay/internal/store"
1919)
2020
21// followServer starts an embedded server holding alice, her public repo
22// alice/app and a queued build 1 whose log has one line, and returns it
23// with a client connected as alice.
24func followServer(t *testing.T) (*Server, *ssh.Client) {
21// testServer is an embedded server over a fresh store holding alice
22// with one full-scope key, and a client connected with that key.
23type testServer struct {
24 srv *Server
25 st *store.Store
26 client *ssh.Client
27 uid int64
28 keyID int64
29 fp string
30}
31
32func newTestServer(t *testing.T) testServer {
2533 t.Helper()
2634 root := t.TempDir()
2735 st, err := store.Open(filepath.Join(root, "gitbay.db"))
@@ -36,17 +44,6 @@ func followServer(t *testing.T) (*Server, *ssh.Client) {
3644 if err != nil {
3745 t.Fatal(err)
3846 }
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 }
5047 _, priv, err := ed25519.GenerateKey(rand.Reader)
5148 if err != nil {
5249 t.Fatal(err)
@@ -56,7 +53,12 @@ func followServer(t *testing.T) (*Server, *ssh.Client) {
5653 t.Fatal(err)
5754 }
5855 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 {
6062 t.Fatal(err)
6163 }
6264
@@ -83,7 +85,34 @@ func followServer(t *testing.T) (*Server, *ssh.Client) {
8385 t.Fatal(err)
8486 }
8587 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.
93func 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.
111func followServer(t *testing.T) (*Server, *ssh.Client) {
112 t.Helper()
113 ts := newTestServer(t)
114 withBuild(t, ts)
115 return ts.srv, ts.client
87116}
88117
89118// startFollow runs build log --follow on a new session and returns once
internal/store/revoke.go added +60
@@ -0,0 +1,60 @@
1package store
2
3import (
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.
10type 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.
18func (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.
26func (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.
37func (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 @@
1package store
2
3import (
4 "slices"
5 "testing"
6)
7
8func 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
23func 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
32func 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
69func 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
79func 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 {
2727 // by the next change to that build's row (BuildLogWait).
2828 logMu sync.Mutex
2929 logWait map[int64]chan struct{}
30
31 // onRevoke runs after each key revocation this process commits.
32 revokeMu sync.Mutex
33 onRevoke []func(Revoked)
3034}
3135
3236// 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 {
9595 if n, _ := res.RowsAffected(); n == 0 {
9696 return ErrNotFound
9797 }
98 s.announce(Revoked{UserID: id})
9899 return nil
99100}
100101
@@ -171,7 +172,8 @@ func (s *Store) ListEmails(userID int64) ([]Email, error) {
171172// SetUserDisabled suspends or restores an account. Disabling drops every
172173// credential that would grant a session on its own — web sessions, API
173174// 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.
175177func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
176178 v := 0
177179 if disabled {
@@ -192,6 +194,7 @@ func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
192194 return err
193195 }
194196 }
197 s.announce(Revoked{UserID: userID})
195198 }
196199 return err
197200}
@@ -288,24 +291,30 @@ func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, s
288291 return tx.Commit()
289292}
290293
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.
292296func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error {
293297 tx, err := s.DB.Begin()
294298 if err != nil {
295299 return err
296300 }
297301 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 }
299307 if err != nil {
300308 return err
301309 }
302 if n, _ := res.RowsAffected(); n == 0 {
303 return ErrNotFound
304 }
305310 if err := bumpKeyEpoch(tx); err != nil {
306311 return err
307312 }
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
309318}
310319
311320// 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 {
538547 return err
539548 }
540549 defer tx.Rollback()
541 res, err := tx.Exec(
542 "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%'",
543 fingerprint, repoID)
550 var id int64
551 err = tx.QueryRow(
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 }
544557 if err != nil {
545558 return err
546559 }
547 if n, _ := res.RowsAffected(); n == 0 {
548 return ErrNotFound
549 }
550560 if err := bumpKeyEpoch(tx); err != nil {
551561 return err
552562 }
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
554568}