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