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