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 {
126126 if ee, ok := err.(*exec.ExitError); ok {
127127 code := ee.ExitCode()
128128 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")
129131 return protocol.ExitProtocol
130132 }
131133 return code
e2e/shutdown_test.go +49
@@ -1,7 +1,11 @@
11package e2e
22
33import (
4 "fmt"
45 "net/http"
6 "os/exec"
7 "path/filepath"
8 "strings"
59 "syscall"
610 "testing"
711 "time"
@@ -32,3 +36,48 @@ func TestServeStopsOnSIGTERM(t *testing.T) {
3236 t.Fatal("http listener still answering after shutdown")
3337 }
3438}
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 (
1818 "strconv"
1919 "strings"
2020 "sync"
21 "sync/atomic"
2122 "time"
2223
2324 "golang.org/x/crypto/ssh"
@@ -37,10 +38,21 @@ type Server struct {
3738 sshCfg *ssh.ServerConfig
3839 authLimiter *rateLimiter
3940 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
4052}
4153
4254func 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{}{}}
4456
4557 sc := &ssh.ServerConfig{
4658 PublicKeyCallback: s.authenticate,
@@ -116,6 +128,13 @@ func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Pe
116128 }
117129 fp := ssh.FingerprintSHA256(pub)
118130 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 }
119138 if err != nil {
120139 if s.cfg.Registration.Mode != "closed" {
121140 return &ssh.Permissions{Extensions: map[string]string{
@@ -138,22 +157,38 @@ func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Pe
138157// Serve accepts connections on ln until it is closed.
139158func (s *Server) Serve(ln net.Listener) error {
140159 for {
141 conn, err := ln.Accept()
160 nc, err := ln.Accept()
142161 if err != nil {
143162 return err
144163 }
164 c := &conn{net: nc}
165 s.mu.Lock()
166 s.conns[c] = struct{}{}
167 s.mu.Unlock()
145168 s.sessions.Add(1)
146169 go func() {
147170 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)
149177 }()
150178 }
151179}
152180
153// Shutdown waits for every accepted connection to finish, or for ctx. The
154// caller closes the listener first; a push in flight completes rather
155// than being cut mid-pack.
181// Shutdown closes every idle connection, then waits for the ones with a
182// session running, or for ctx. The caller closes the listener first; a
183// push in flight completes rather than being cut mid-pack.
156184func (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()
157192 done := make(chan struct{})
158193 go func() {
159194 s.sessions.Wait()
@@ -167,9 +202,9 @@ func (s *Server) Shutdown(ctx context.Context) error {
167202 }
168203}
169204
170func (s *Server) handleConn(conn net.Conn) {
171 defer conn.Close()
172 sconn, chans, reqs, err := ssh.NewServerConn(conn, s.sshCfg)
205func (s *Server) handleConn(c *conn) {
206 defer c.net.Close()
207 sconn, chans, reqs, err := ssh.NewServerConn(c.net, s.sshCfg)
173208 if err != nil {
174209 return
175210 }
@@ -185,7 +220,11 @@ func (s *Server) handleConn(conn net.Conn) {
185220 if err != nil {
186221 continue
187222 }
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 }()
189228 }
190229}
191230