1package server
2
3import (
4 "context"
5 "errors"
6 "net"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "github.com/charmbracelet/log"
12 "github.com/charmbracelet/soft-serve/server/backend"
13 cm "github.com/charmbracelet/soft-serve/server/cmd"
14 "github.com/charmbracelet/soft-serve/server/config"
15 "github.com/charmbracelet/ssh"
16 "github.com/charmbracelet/wish"
17 bm "github.com/charmbracelet/wish/bubbletea"
18 lm "github.com/charmbracelet/wish/logging"
19 rm "github.com/charmbracelet/wish/recover"
20 "github.com/muesli/termenv"
21 gossh "golang.org/x/crypto/ssh"
22)
23
24// SSHServer is a SSH server that implements the git protocol.
25type SSHServer struct {
26 srv *ssh.Server
27 cfg *config.Config
28}
29
30// NewSSHServer returns a new SSHServer.
31func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
32 var err error
33 s := &SSHServer{cfg: cfg}
34 logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
35 mw := []wish.Middleware{
36 rm.MiddlewareWithLogger(
37 logger,
38 // BubbleTea middleware.
39 bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
40 // CLI middleware.
41 cm.Middleware(cfg),
42 // Git middleware.
43 s.Middleware(cfg),
44 // Logging middleware.
45 lm.MiddlewareWithLogger(logger),
46 ),
47 }
48 s.srv, err = wish.NewServer(
49 ssh.PublicKeyAuth(s.PublicKeyHandler),
50 ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
51 wish.WithAddress(cfg.SSH.ListenAddr),
52 wish.WithHostKeyPath(cfg.SSH.KeyPath),
53 wish.WithMiddleware(mw...),
54 )
55 if err != nil {
56 return nil, err
57 }
58
59 if cfg.SSH.MaxTimeout > 0 {
60 s.srv.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second
61 }
62 if cfg.SSH.IdleTimeout > 0 {
63 s.srv.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second
64 }
65
66 return s, nil
67}
68
69// ListenAndServe starts the SSH server.
70func (s *SSHServer) ListenAndServe() error {
71 return s.srv.ListenAndServe()
72}
73
74// Serve starts the SSH server on the given net.Listener.
75func (s *SSHServer) Serve(l net.Listener) error {
76 return s.srv.Serve(l)
77}
78
79// Close closes the SSH server.
80func (s *SSHServer) Close() error {
81 return s.srv.Close()
82}
83
84// Shutdown gracefully shuts down the SSH server.
85func (s *SSHServer) Shutdown(ctx context.Context) error {
86 return s.srv.Shutdown(ctx)
87}
88
89// PublicKeyAuthHandler handles public key authentication.
90func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
91 return s.cfg.Access.AccessLevel("", pk) > backend.NoAccess
92}
93
94// KeyboardInteractiveHandler handles keyboard interactive authentication.
95func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
96 return s.cfg.Backend.AllowKeyless() && s.PublicKeyHandler(ctx, nil)
97}
98
99// Middleware adds Git server functionality to the ssh.Server. Repos are stored
100// in the specified repo directory. The provided Hooks implementation will be
101// checked for access on a per repo basis for a ssh.Session public key.
102// Hooks.Push and Hooks.Fetch will be called on successful completion of
103// their commands.
104func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
105 return func(sh ssh.Handler) ssh.Handler {
106 return func(s ssh.Session) {
107 func() {
108 cmd := s.Command()
109 if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
110 gc := cmd[0]
111 // repo should be in the form of "repo.git"
112 name := sanitizeRepoName(cmd[1])
113 pk := s.PublicKey()
114 access := cfg.Access.AccessLevel(name, pk)
115 // git bare repositories should end in ".git"
116 // https://git-scm.com/docs/gitrepository-layout
117 repo := name + ".git"
118
119 reposDir := cfg.Backend.RepositoryStorePath()
120 if err := ensureWithin(reposDir, repo); err != nil {
121 sshFatal(s, err)
122 return
123 }
124
125 repoDir := filepath.Join(reposDir, repo)
126 switch gc {
127 case ReceivePackBin:
128 if access < backend.ReadWriteAccess {
129 sshFatal(s, ErrNotAuthed)
130 return
131 }
132 if _, err := cfg.Backend.Repository(name); err != nil {
133 if _, err := cfg.Backend.CreateRepository(name, false); err != nil {
134 log.Printf("failed to create repo: %s", err)
135 sshFatal(s, err)
136 return
137 }
138 }
139 if err := ReceivePack(s, s, s.Stderr(), repoDir); err != nil {
140 sshFatal(s, ErrSystemMalfunction)
141 }
142 return
143 case UploadPackBin, UploadArchiveBin:
144 if access < backend.ReadOnlyAccess {
145 sshFatal(s, ErrNotAuthed)
146 return
147 }
148 gitPack := UploadPack
149 if gc == UploadArchiveBin {
150 gitPack = UploadArchive
151 }
152 err := gitPack(s, s, s.Stderr(), repoDir)
153 if errors.Is(err, ErrInvalidRepo) {
154 sshFatal(s, ErrInvalidRepo)
155 } else if err != nil {
156 sshFatal(s, ErrSystemMalfunction)
157 }
158 }
159 }
160 }()
161 sh(s)
162 }
163 }
164}
165
166// sshFatal prints to the session's STDOUT as a git response and exit 1.
167func sshFatal(s ssh.Session, v ...interface{}) {
168 WritePktline(s, v...)
169 s.Exit(1) // nolint: errcheck
170}
171
172func sanitizeRepoName(repo string) string {
173 repo = strings.TrimPrefix(repo, "/")
174 repo = filepath.Clean(repo)
175 repo = strings.TrimSuffix(repo, ".git")
176 return repo
177}