sshd.go
⎇
Raw
1// Package sshd serves git over SSH. It accepts public-key auth only and
2// runs git-upload-pack / git-receive-pack for the user "git".
3package sshd
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "regexp"
15 "strings"
16 "sync"
17
18 "github.com/gliderlabs/ssh"
19 gossh "golang.org/x/crypto/ssh"
20
21 "hearthforge/internal/ci"
22 "hearthforge/internal/config"
23 "hearthforge/internal/db"
24 "hearthforge/internal/gitcmd"
25)
26
27// Server runs the SSH git endpoint.
28type Server struct {
29 Cfg *config.Config
30 DB *db.DB
31 CI *ci.Runner
32 // OnPush is called after every receive-pack so callers can drop caches.
33 // It may be nil.
34 OnPush func(repo string)
35
36 mu sync.Mutex
37 srv *ssh.Server
38}
39
40// Close stops the SSH listener. It is safe to call before ListenAndServe.
41func (s *Server) Close() error {
42 s.mu.Lock()
43 srv := s.srv
44 s.mu.Unlock()
45 if srv == nil {
46 return nil
47 }
48 return srv.Close()
49}
50
51// userKey carries the authenticated username into the session handler.
52type userKeyType struct{}
53
54var userKey userKeyType
55
56// execRe matches the two commands git sends, e.g. "git-upload-pack '/repo.git'".
57var execRe = regexp.MustCompile(`^(git-upload-pack|git-receive-pack)\s+'?/?([a-zA-Z0-9_.-]+?)(?:\.git)?'?$`)
58
59func (s *Server) ListenAndServe() error {
60 // The host key is created by gitcmd.EnsureSigningSetup. The startup sync
61 // runs it before the SSH listener starts.
62 pem, err := os.ReadFile(s.Cfg.SSHHostKeyPath)
63 if err != nil {
64 return err
65 }
66 signer, err := gossh.ParsePrivateKey(pem)
67 if err != nil {
68 return fmt.Errorf("parse ssh host key: %w", err)
69 }
70
71 srv := &ssh.Server{
72 Addr: fmt.Sprintf("0.0.0.0:%d", s.Cfg.SSHPort),
73 HostSigners: []ssh.Signer{signer},
74 PublicKeyHandler: s.publicKey,
75 Handler: s.session,
76 }
77 s.mu.Lock()
78 s.srv = srv
79 s.mu.Unlock()
80 log.Printf("SSH server listening on port %d", s.Cfg.SSHPort)
81 return srv.ListenAndServe()
82}
83
84// publicKey accepts a key when it belongs to a non-pending user. The library
85// has already verified the signature against the offered key.
86func (s *Server) publicKey(ctx ssh.Context, key ssh.PublicKey) bool {
87 if ctx.User() != "git" {
88 return false
89 }
90 owner, err := s.DB.SSHKeyByFingerprint(ctx, gossh.FingerprintSHA256(key))
91 if err != nil {
92 log.Printf("ssh key lookup failed: %v", err)
93 return false
94 }
95 if owner == nil {
96 return false
97 }
98 // The stored key text must still match the offered key. A fingerprint
99 // collision or a mangled row would otherwise grant access.
100 stored, _, _, _, err := ssh.ParseAuthorizedKey([]byte(owner.PublicKey))
101 if err != nil || !ssh.KeysEqual(stored, key) {
102 return false
103 }
104 ctx.SetValue(userKey, owner.Username)
105 return true
106}
107
108func fail(sess ssh.Session, msg string) {
109 io.WriteString(sess.Stderr(), msg)
110 sess.Exit(128)
111}
112
113func (s *Server) session(sess ssh.Session) {
114 username, _ := sess.Context().Value(userKey).(string)
115
116 m := execRe.FindStringSubmatch(strings.TrimSpace(sess.RawCommand()))
117 if m == nil {
118 fail(sess, "error: only git-upload-pack and git-receive-pack are supported\n")
119 return
120 }
121 command, repoName := m[1], m[2]
122 if !gitcmd.ValidRepoName(repoName) {
123 fail(sess, "error: invalid repository name\n")
124 return
125 }
126
127 repo, err := s.DB.RepoByName(sess.Context(), repoName)
128 if err != nil {
129 log.Printf("ssh repo lookup failed: %v", err)
130 fail(sess, "error: internal error\n")
131 return
132 }
133 if repo == nil {
134 fail(sess, "error: repository not found\n")
135 return
136 }
137
138 isAdmin := username == db.AdminUsername
139 if command == "git-receive-pack" && !isAdmin {
140 fail(sess, "error: push access denied\n")
141 return
142 }
143 // Private repos are admin-only, matching the UI and the smart-HTTP path.
144 if repo.IsPrivate && !isAdmin {
145 fail(sess, "error: repository access denied\n")
146 return
147 }
148
149 code, preamble := s.runGit(sess, command, filepath.Join(s.Cfg.ReposDir(), repo.Name+".git"))
150 if command == "git-receive-pack" {
151 if s.OnPush != nil {
152 s.OnPush(repo.Name)
153 }
154 if code == 0 {
155 // Run CI detached. The session ends as soon as git exits.
156 go s.CI.TriggerForPush(context.Background(), repo.Name, preamble)
157 }
158 }
159 sess.Exit(code)
160}
161
162// runGit pipes the session through the git subprocess. For receive-pack it
163// also returns the first bytes of stdin.
164func (s *Server) runGit(sess ssh.Session, command, repoPath string) (int, []byte) {
165 cmd := exec.CommandContext(sess.Context(), command, repoPath)
166 cmd.Env = gitcmd.Env()
167
168 var preamble *ci.CapWriter
169 stdin := io.Reader(sess)
170 if command == "git-receive-pack" {
171 preamble = &ci.CapWriter{Limit: ci.PreambleMax}
172 stdin = io.TeeReader(sess, preamble)
173 }
174 cmd.Stdout = sess
175 cmd.Stderr = sess.Stderr()
176 // Feed stdin through a pipe in a goroutine. Waiting on the copy would
177 // hang until the client closes its side, which can happen after git exits.
178 in, err := cmd.StdinPipe()
179 if err != nil {
180 log.Printf("%s stdin pipe failed: %v", command, err)
181 return 128, nil
182 }
183 if err := cmd.Start(); err != nil {
184 log.Printf("%s failed to start for %s: %v", command, repoPath, err)
185 return 128, nil
186 }
187 go func() {
188 io.Copy(in, stdin)
189 in.Close()
190 }()
191
192 if err := cmd.Wait(); err != nil {
193 var exit *exec.ExitError
194 if errors.As(err, &exit) {
195 return exit.ExitCode(), preamble.Bytes()
196 }
197 log.Printf("%s failed for %s: %v", command, repoPath, err)
198 return 128, preamble.Bytes()
199 }
200 return 0, preamble.Bytes()
201}
202