// Package sshd serves git over SSH. It accepts public-key auth only and // runs git-upload-pack / git-receive-pack for the user "git". package sshd import ( "context" "errors" "fmt" "io" "log" "os" "os/exec" "path/filepath" "regexp" "strings" "sync" "github.com/gliderlabs/ssh" gossh "golang.org/x/crypto/ssh" "hearthforge/internal/ci" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/gitcmd" ) // Server runs the SSH git endpoint. type Server struct { Cfg *config.Config DB *db.DB CI *ci.Runner // OnPush is called after every receive-pack so callers can drop caches. // It may be nil. OnPush func(repo string) mu sync.Mutex srv *ssh.Server } // Close stops the SSH listener. It is safe to call before ListenAndServe. func (s *Server) Close() error { s.mu.Lock() srv := s.srv s.mu.Unlock() if srv == nil { return nil } return srv.Close() } // userKey carries the authenticated username into the session handler. type userKeyType struct{} var userKey userKeyType // execRe matches the two commands git sends, e.g. "git-upload-pack '/repo.git'". var execRe = regexp.MustCompile(`^(git-upload-pack|git-receive-pack)\s+'?/?([a-zA-Z0-9_.-]+?)(?:\.git)?'?$`) func (s *Server) ListenAndServe() error { // The host key is created by gitcmd.EnsureSigningSetup. The startup sync // runs it before the SSH listener starts. pem, err := os.ReadFile(s.Cfg.SSHHostKeyPath) if err != nil { return err } signer, err := gossh.ParsePrivateKey(pem) if err != nil { return fmt.Errorf("parse ssh host key: %w", err) } srv := &ssh.Server{ Addr: fmt.Sprintf("0.0.0.0:%d", s.Cfg.SSHPort), HostSigners: []ssh.Signer{signer}, PublicKeyHandler: s.publicKey, Handler: s.session, } s.mu.Lock() s.srv = srv s.mu.Unlock() log.Printf("SSH server listening on port %d", s.Cfg.SSHPort) return srv.ListenAndServe() } // publicKey accepts a key when it belongs to a non-pending user. The library // has already verified the signature against the offered key. func (s *Server) publicKey(ctx ssh.Context, key ssh.PublicKey) bool { if ctx.User() != "git" { return false } owner, err := s.DB.SSHKeyByFingerprint(ctx, gossh.FingerprintSHA256(key)) if err != nil { log.Printf("ssh key lookup failed: %v", err) return false } if owner == nil { return false } // The stored key text must still match the offered key. A fingerprint // collision or a mangled row would otherwise grant access. stored, _, _, _, err := ssh.ParseAuthorizedKey([]byte(owner.PublicKey)) if err != nil || !ssh.KeysEqual(stored, key) { return false } ctx.SetValue(userKey, owner.Username) return true } func fail(sess ssh.Session, msg string) { io.WriteString(sess.Stderr(), msg) sess.Exit(128) } func (s *Server) session(sess ssh.Session) { username, _ := sess.Context().Value(userKey).(string) m := execRe.FindStringSubmatch(strings.TrimSpace(sess.RawCommand())) if m == nil { fail(sess, "error: only git-upload-pack and git-receive-pack are supported\n") return } command, repoName := m[1], m[2] if !gitcmd.ValidRepoName(repoName) { fail(sess, "error: invalid repository name\n") return } repo, err := s.DB.RepoByName(sess.Context(), repoName) if err != nil { log.Printf("ssh repo lookup failed: %v", err) fail(sess, "error: internal error\n") return } if repo == nil { fail(sess, "error: repository not found\n") return } isAdmin := username == db.AdminUsername if command == "git-receive-pack" && !isAdmin { fail(sess, "error: push access denied\n") return } // Private repos are admin-only, matching the UI and the smart-HTTP path. if repo.IsPrivate && !isAdmin { fail(sess, "error: repository access denied\n") return } code, preamble := s.runGit(sess, command, filepath.Join(s.Cfg.ReposDir(), repo.Name+".git")) if command == "git-receive-pack" { if s.OnPush != nil { s.OnPush(repo.Name) } if code == 0 { // Run CI detached. The session ends as soon as git exits. go s.CI.TriggerForPush(context.Background(), repo.Name, preamble) } } sess.Exit(code) } // runGit pipes the session through the git subprocess. For receive-pack it // also returns the first bytes of stdin. func (s *Server) runGit(sess ssh.Session, command, repoPath string) (int, []byte) { cmd := exec.CommandContext(sess.Context(), command, repoPath) cmd.Env = gitcmd.Env() var preamble *ci.CapWriter stdin := io.Reader(sess) if command == "git-receive-pack" { preamble = &ci.CapWriter{Limit: ci.PreambleMax} stdin = io.TeeReader(sess, preamble) } cmd.Stdout = sess cmd.Stderr = sess.Stderr() // Feed stdin through a pipe in a goroutine. Waiting on the copy would // hang until the client closes its side, which can happen after git exits. in, err := cmd.StdinPipe() if err != nil { log.Printf("%s stdin pipe failed: %v", command, err) return 128, nil } if err := cmd.Start(); err != nil { log.Printf("%s failed to start for %s: %v", command, repoPath, err) return 128, nil } go func() { io.Copy(in, stdin) in.Close() }() if err := cmd.Wait(); err != nil { var exit *exec.ExitError if errors.As(err, &exit) { return exit.ExitCode(), preamble.Bytes() } log.Printf("%s failed for %s: %v", command, repoPath, err) return 128, preamble.Bytes() } return 0, preamble.Bytes() }