Commit d299dba2d3

d299dba2d3e319a9ac4488f03ca00818e55aaf54

parent: 6821a6f760

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 23:43 UTC

sshd: close idle connections on shutdown, and do not count a store failure as a bad key

A CLI keeps a shared connection open between commands, and Shutdown
waited for it like a session in flight: a deploy's restart held the
drain for its full 30 s on one idle connection. Each connection now
counts its running sessions; Shutdown closes the idle ones at once and
waits only for sessions mid-command.

authenticate treated any error from the key lookup as an unknown key:
a failure against the limiter and a denial. A busy database during a
restart could lock every client out for a minute. A store error other
than not-found is now a temporary refusal that counts nothing.

The CLI says, on ssh's exit 255, that a burst of connections may have
tripped the instance's auth rate limit, since ssh's own message cannot
tell a bad key from a throttle.

TestShutdownClosesIdleConnections opens a control master with no
session and expects the daemon to exit within seconds of SIGTERM.

Closes #141

Layout: unified · split

cmd/gitbay/ssh.go +2
@@ -126,6 +126,8 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int {
126 if ee, ok := err.(*exec.ExitError); ok { 126 if ee, ok := err.(*exec.ExitError); ok {
127 code := ee.ExitCode() 127 code := ee.ExitCode()
128 if code == 255 { // ssh-level failure (connection, auth, host key) 128 if code == 255 { // ssh-level failure (connection, auth, host key)
129 fmt.Fprintln(os.Stderr, "gitbay: ssh could not connect or authenticate; if this worked a moment ago,"+
130 " the instance may be rate-limiting authentication after a burst of connections: wait a minute and retry")
129 return protocol.ExitProtocol 131 return protocol.ExitProtocol
130 } 132 }
131 return code 133 return code
e2e/shutdown_test.go +49
@@ -1,7 +1,11 @@
1package e2e 1package e2e
2 2
3import ( 3import (
4 "fmt"
4 "net/http" 5 "net/http"
6 "os/exec"
7 "path/filepath"
8 "strings"
5 "syscall" 9 "syscall"
6 "testing" 10 "testing"
7 "time" 11 "time"
@@ -32,3 +36,48 @@ func TestServeStopsOnSIGTERM(t *testing.T) {
32 t.Fatal("http listener still answering after shutdown") 36 t.Fatal("http listener still answering after shutdown")
33 } 37 }
34} 38}
39
40// A CLI keeps a shared connection open between commands. On shutdown that
41// idle connection is closed at once rather than holding the drain for its
42// full 30 s; only a session mid-command is waited for (#141).
43func TestShutdownClosesIdleConnections(t *testing.T) {
44 inst := startInstance(t)
45 aliceKey := inst.newKey(t, "alice")
46 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
47
48 // A control master with no session: -N, backgrounded, persistent.
49 // A unix socket path has a short limit; t.TempDir is far past it here.
50 sock := fmt.Sprintf("/tmp/gitbay-e2e-cm-%d", inst.port)
51 base := []string{"-p", fmt.Sprint(inst.port), "-i", aliceKey, "-o", "IdentitiesOnly=yes",
52 "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=" + filepath.Join(inst.sshDir, "known_hosts"),
53 "-o", "BatchMode=yes", "-o", "ControlPath=" + sock}
54 master := exec.Command("ssh", append(append([]string{}, base...), "-o", "ControlMaster=yes", "-o", "ControlPersist=yes", "-N", "-f", "git@127.0.0.1")...)
55 if out, err := master.CombinedOutput(); err != nil {
56 t.Fatalf("control master: %v\n%s", err, out)
57 }
58 t.Cleanup(func() {
59 exec.Command("ssh", append(append([]string{}, base...), "-O", "exit", "git@127.0.0.1")...).Run()
60 })
61 // The shared connection works.
62 if out, err := exec.Command("ssh", append(append([]string{}, base...), "git@127.0.0.1", "whoami")...).Output(); err != nil || strings.TrimSpace(string(out)) != "alice" {
63 t.Fatalf("multiplexed whoami: %v %q", err, out)
64 }
65
66 start := time.Now()
67 if err := inst.proc.Process.Signal(syscall.SIGTERM); err != nil {
68 t.Fatal(err)
69 }
70 done := make(chan error, 1)
71 go func() { done <- inst.proc.Wait() }()
72 select {
73 case err := <-done:
74 if err != nil {
75 t.Fatalf("daemon did not exit cleanly: %v", err)
76 }
77 case <-time.After(20 * time.Second):
78 t.Fatal("daemon still running 20s after SIGTERM with only an idle connection open")
79 }
80 if took := time.Since(start); took > 5*time.Second {
81 t.Fatalf("shutdown took %s with only an idle connection open", took)
82 }
83}
internal/sshd/sshd.go +49 −10
@@ -18,6 +18,7 @@ import (
18 "strconv" 18 "strconv"
19 "strings" 19 "strings"
20 "sync" 20 "sync"
21 "sync/atomic"
21 "time" 22 "time"
22 23
23 "golang.org/x/crypto/ssh" 24 "golang.org/x/crypto/ssh"
@@ -37,10 +38,21 @@ type Server struct {
37 sshCfg *ssh.ServerConfig 38 sshCfg *ssh.ServerConfig
38 authLimiter *rateLimiter 39 authLimiter *rateLimiter
39 sessions sync.WaitGroup // accepted connections still being served 40 sessions sync.WaitGroup // accepted connections still being served
41 mu sync.Mutex
42 conns map[*conn]struct{}
43}
44
45// conn is one accepted connection and how many sessions it is running.
46// A CLI's shared connection sits idle between commands; on shutdown an
47// idle connection is closed at once and only a session mid-command is
48// waited for (#141).
49type conn struct {
50 net net.Conn
51 active atomic.Int32
40} 52}
41 53
42func New(cfg config.Config, st *store.Store) (*Server, error) { 54func New(cfg config.Config, st *store.Store) (*Server, error) {
43 s := &Server{cfg: cfg, st: st, authLimiter: newRateLimiter(cfg.Limits.SSHAuthRate, time.Minute)} 55 s := &Server{cfg: cfg, st: st, authLimiter: newRateLimiter(cfg.Limits.SSHAuthRate, time.Minute), conns: map[*conn]struct{}{}}
44 56
45 sc := &ssh.ServerConfig{ 57 sc := &ssh.ServerConfig{
46 PublicKeyCallback: s.authenticate, 58 PublicKeyCallback: s.authenticate,
@@ -116,6 +128,13 @@ func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Pe
116 } 128 }
117 fp := ssh.FingerprintSHA256(pub) 129 fp := ssh.FingerprintSHA256(pub)
118 key, err := s.st.SSHKeyByFingerprint(fp) 130 key, err := s.st.SSHKeyByFingerprint(fp)
131 if err != nil && !errors.Is(err, store.ErrNotFound) {
132 // The store, not the key, failed. Neither a failure against the
133 // limiter nor "unknown key": a busy database during a restart
134 // would otherwise lock every client out for a minute.
135 slog.Error("ssh auth: key lookup", "err", err)
136 return nil, fmt.Errorf("authentication temporarily unavailable")
137 }
119 if err != nil { 138 if err != nil {
120 if s.cfg.Registration.Mode != "closed" { 139 if s.cfg.Registration.Mode != "closed" {
121 return &ssh.Permissions{Extensions: map[string]string{ 140 return &ssh.Permissions{Extensions: map[string]string{
@@ -138,22 +157,38 @@ func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Pe
138// Serve accepts connections on ln until it is closed. 157// Serve accepts connections on ln until it is closed.
139func (s *Server) Serve(ln net.Listener) error { 158func (s *Server) Serve(ln net.Listener) error {
140 for { 159 for {
141 conn, err := ln.Accept() 160 nc, err := ln.Accept()
142 if err != nil { 161 if err != nil {
143 return err 162 return err
144 } 163 }
164 c := &conn{net: nc}
165 s.mu.Lock()
166 s.conns[c] = struct{}{}
167 s.mu.Unlock()
145 s.sessions.Add(1) 168 s.sessions.Add(1)
146 go func() { 169 go func() {
147 defer s.sessions.Done() 170 defer s.sessions.Done()
148 s.handleConn(conn) 171 defer func() {
172 s.mu.Lock()
173 delete(s.conns, c)
174 s.mu.Unlock()
175 }()
176 s.handleConn(c)
149 }() 177 }()
150 } 178 }
151} 179}
152 180
153// Shutdown waits for every accepted connection to finish, or for ctx. The 181// Shutdown closes every idle connection, then waits for the ones with a
154// caller closes the listener first; a push in flight completes rather 182// session running, or for ctx. The caller closes the listener first; a
155// than being cut mid-pack. 183// push in flight completes rather than being cut mid-pack.
156func (s *Server) Shutdown(ctx context.Context) error { 184func (s *Server) Shutdown(ctx context.Context) error {
185 s.mu.Lock()
186 for c := range s.conns {
187 if c.active.Load() == 0 {
188 c.net.Close()
189 }
190 }
191 s.mu.Unlock()
157 done := make(chan struct{}) 192 done := make(chan struct{})
158 go func() { 193 go func() {
159 s.sessions.Wait() 194 s.sessions.Wait()
@@ -167,9 +202,9 @@ func (s *Server) Shutdown(ctx context.Context) error {
167 } 202 }
168} 203}
169 204
170func (s *Server) handleConn(conn net.Conn) { 205func (s *Server) handleConn(c *conn) {
171 defer conn.Close() 206 defer c.net.Close()
172 sconn, chans, reqs, err := ssh.NewServerConn(conn, s.sshCfg) 207 sconn, chans, reqs, err := ssh.NewServerConn(c.net, s.sshCfg)
173 if err != nil { 208 if err != nil {
174 return 209 return
175 } 210 }
@@ -185,7 +220,11 @@ func (s *Server) handleConn(conn net.Conn) {
185 if err != nil { 220 if err != nil {
186 continue 221 continue
187 } 222 }
188 go s.handleSession(sconn, ch, chReqs) 223 c.active.Add(1)
224 go func() {
225 defer c.active.Add(-1)
226 s.handleSession(sconn, ch, chReqs)
227 }()
189 } 228 }
190} 229}
191 230