1package server
2
3import (
4 "context"
5 "errors"
6 "net"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "time"
11
12 "github.com/charmbracelet/log"
13 "github.com/charmbracelet/soft-serve/server/backend"
14 cm "github.com/charmbracelet/soft-serve/server/cmd"
15 "github.com/charmbracelet/soft-serve/server/config"
16 "github.com/charmbracelet/soft-serve/server/hooks"
17 "github.com/charmbracelet/soft-serve/server/utils"
18 "github.com/charmbracelet/ssh"
19 "github.com/charmbracelet/wish"
20 bm "github.com/charmbracelet/wish/bubbletea"
21 lm "github.com/charmbracelet/wish/logging"
22 rm "github.com/charmbracelet/wish/recover"
23 "github.com/muesli/termenv"
24 "github.com/prometheus/client_golang/prometheus"
25 "github.com/prometheus/client_golang/prometheus/promauto"
26 gossh "golang.org/x/crypto/ssh"
27)
28
29var (
30 publicKeyCounter = promauto.NewCounterVec(prometheus.CounterOpts{
31 Namespace: "soft_serve",
32 Subsystem: "ssh",
33 Name: "public_key_auth_total",
34 Help: "The total number of public key auth requests",
35 }, []string{"key", "user", "access", "allowed"})
36
37 keyboardInteractiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
38 Namespace: "soft_serve",
39 Subsystem: "ssh",
40 Name: "keyboard_interactive_auth_total",
41 Help: "The total number of keyboard interactive auth requests",
42 }, []string{"user", "allowed"})
43
44 uploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
45 Namespace: "soft_serve",
46 Subsystem: "ssh",
47 Name: "git_upload_pack_total",
48 Help: "The total number of git-upload-pack requests",
49 }, []string{"key", "user", "repo"})
50
51 receivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
52 Namespace: "soft_serve",
53 Subsystem: "ssh",
54 Name: "git_receive_pack_total",
55 Help: "The total number of git-receive-pack requests",
56 }, []string{"key", "user", "repo"})
57
58 uploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
59 Namespace: "soft_serve",
60 Subsystem: "ssh",
61 Name: "git_upload_archive_total",
62 Help: "The total number of git-upload-archive requests",
63 }, []string{"key", "user", "repo"})
64
65 createRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{
66 Namespace: "soft_serve",
67 Subsystem: "ssh",
68 Name: "create_repo_total",
69 Help: "The total number of create repo requests",
70 }, []string{"key", "user", "repo"})
71)
72
73// SSHServer is a SSH server that implements the git protocol.
74type SSHServer struct {
75 srv *ssh.Server
76 cfg *config.Config
77}
78
79// NewSSHServer returns a new SSHServer.
80func NewSSHServer(cfg *config.Config, hooks hooks.Hooks) (*SSHServer, error) {
81 var err error
82 s := &SSHServer{cfg: cfg}
83 logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
84 mw := []wish.Middleware{
85 rm.MiddlewareWithLogger(
86 logger,
87 // BubbleTea middleware.
88 bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
89 // CLI middleware.
90 cm.Middleware(cfg, hooks),
91 // Git middleware.
92 s.Middleware(cfg),
93 // Logging middleware.
94 lm.MiddlewareWithLogger(logger),
95 ),
96 }
97 s.srv, err = wish.NewServer(
98 ssh.PublicKeyAuth(s.PublicKeyHandler),
99 ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
100 wish.WithAddress(cfg.SSH.ListenAddr),
101 wish.WithHostKeyPath(filepath.Join(cfg.DataPath, cfg.SSH.KeyPath)),
102 wish.WithMiddleware(mw...),
103 )
104 if err != nil {
105 return nil, err
106 }
107
108 if cfg.SSH.MaxTimeout > 0 {
109 s.srv.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second
110 }
111 if cfg.SSH.IdleTimeout > 0 {
112 s.srv.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second
113 }
114
115 return s, nil
116}
117
118// ListenAndServe starts the SSH server.
119func (s *SSHServer) ListenAndServe() error {
120 return s.srv.ListenAndServe()
121}
122
123// Serve starts the SSH server on the given net.Listener.
124func (s *SSHServer) Serve(l net.Listener) error {
125 return s.srv.Serve(l)
126}
127
128// Close closes the SSH server.
129func (s *SSHServer) Close() error {
130 return s.srv.Close()
131}
132
133// Shutdown gracefully shuts down the SSH server.
134func (s *SSHServer) Shutdown(ctx context.Context) error {
135 return s.srv.Shutdown(ctx)
136}
137
138// PublicKeyAuthHandler handles public key authentication.
139func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
140 ac := s.cfg.Backend.AccessLevel("", pk)
141 allowed := ac >= backend.ReadOnlyAccess
142 ak := backend.MarshalAuthorizedKey(pk)
143 publicKeyCounter.WithLabelValues(ak, ctx.User(), ac.String(), strconv.FormatBool(allowed)).Inc()
144 return allowed
145}
146
147// KeyboardInteractiveHandler handles keyboard interactive authentication.
148func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
149 ac := s.cfg.Backend.AllowKeyless() && s.PublicKeyHandler(ctx, nil)
150 keyboardInteractiveCounter.WithLabelValues(ctx.User(), strconv.FormatBool(ac)).Inc()
151 return ac
152}
153
154// Middleware adds Git server functionality to the ssh.Server. Repos are stored
155// in the specified repo directory. The provided Hooks implementation will be
156// checked for access on a per repo basis for a ssh.Session public key.
157// Hooks.Push and Hooks.Fetch will be called on successful completion of
158// their commands.
159func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
160 return func(sh ssh.Handler) ssh.Handler {
161 return func(s ssh.Session) {
162 func() {
163 cmd := s.Command()
164 if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
165 gc := cmd[0]
166 // repo should be in the form of "repo.git"
167 name := utils.SanitizeRepo(cmd[1])
168 pk := s.PublicKey()
169 ak := backend.MarshalAuthorizedKey(pk)
170 access := cfg.Backend.AccessLevel(name, pk)
171 // git bare repositories should end in ".git"
172 // https://git-scm.com/docs/gitrepository-layout
173 repo := name + ".git"
174 reposDir := filepath.Join(cfg.DataPath, "repos")
175 if err := ensureWithin(reposDir, repo); err != nil {
176 sshFatal(s, err)
177 return
178 }
179
180 repoDir := filepath.Join(reposDir, repo)
181 switch gc {
182 case receivePackBin:
183 if access < backend.ReadWriteAccess {
184 sshFatal(s, ErrNotAuthed)
185 return
186 }
187 if _, err := cfg.Backend.Repository(name); err != nil {
188 if _, err := cfg.Backend.CreateRepository(name, false); err != nil {
189 log.Errorf("failed to create repo: %s", err)
190 sshFatal(s, err)
191 return
192 }
193 createRepoCounter.WithLabelValues(ak, s.User(), name).Inc()
194 }
195 if err := receivePack(s, s, s.Stderr(), repoDir); err != nil {
196 sshFatal(s, ErrSystemMalfunction)
197 }
198 receivePackCounter.WithLabelValues(ak, s.User(), name).Inc()
199 return
200 case uploadPackBin, uploadArchiveBin:
201 if access < backend.ReadOnlyAccess {
202 sshFatal(s, ErrNotAuthed)
203 return
204 }
205
206 gitPack := uploadPack
207 counter := uploadPackCounter
208 if gc == uploadArchiveBin {
209 gitPack = uploadArchive
210 counter = uploadArchiveCounter
211 }
212
213 err := gitPack(s, s, s.Stderr(), repoDir)
214 if errors.Is(err, ErrInvalidRepo) {
215 sshFatal(s, ErrInvalidRepo)
216 } else if err != nil {
217 sshFatal(s, ErrSystemMalfunction)
218 }
219
220 counter.WithLabelValues(ak, s.User(), name).Inc()
221 }
222 }
223 }()
224 sh(s)
225 }
226 }
227}
228
229// sshFatal prints to the session's STDOUT as a git response and exit 1.
230func sshFatal(s ssh.Session, v ...interface{}) {
231 writePktline(s, v...)
232 s.Exit(1) // nolint: errcheck
233}