| @@ -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, nil); 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 |