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/hooks"
16 "github.com/charmbracelet/soft-serve/server/utils"
17 "github.com/charmbracelet/ssh"
18 "github.com/charmbracelet/wish"
19 bm "github.com/charmbracelet/wish/bubbletea"
20 lm "github.com/charmbracelet/wish/logging"
21 rm "github.com/charmbracelet/wish/recover"
22 "github.com/muesli/termenv"
23 gossh "golang.org/x/crypto/ssh"
24)
25
26// SSHServer is a SSH server that implements the git protocol.
27type SSHServer struct {
28 srv *ssh.Server
29 cfg *config.Config
30}
31
32// NewSSHServer returns a new SSHServer.
33func NewSSHServer(cfg *config.Config, hooks hooks.Hooks) (*SSHServer, error) {
34 var err error
35 s := &SSHServer{cfg: cfg}
36 logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
37 mw := []wish.Middleware{
38 rm.MiddlewareWithLogger(
39 logger,
40 // BubbleTea middleware.
41 bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
42 // CLI middleware.
43 cm.Middleware(cfg, hooks),
44 // Git middleware.
45 s.Middleware(cfg),
46 // Logging middleware.
47 lm.MiddlewareWithLogger(logger),
48 ),
49 }
50 s.srv, err = wish.NewServer(
51 ssh.PublicKeyAuth(s.PublicKeyHandler),
52 ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
53 wish.WithAddress(cfg.SSH.ListenAddr),
54 wish.WithHostKeyPath(filepath.Join(cfg.DataPath, cfg.SSH.KeyPath)),
55 wish.WithMiddleware(mw...),
56 )
57 if err != nil {
58 return nil, err
59 }
60
61 if cfg.SSH.MaxTimeout > 0 {
62 s.srv.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second
63 }
64 if cfg.SSH.IdleTimeout > 0 {
65 s.srv.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second
66 }
67
68 return s, nil
69}
70
71// ListenAndServe starts the SSH server.
72func (s *SSHServer) ListenAndServe() error {
73 return s.srv.ListenAndServe()
74}
75
76// Serve starts the SSH server on the given net.Listener.
77func (s *SSHServer) Serve(l net.Listener) error {
78 return s.srv.Serve(l)
79}
80
81// Close closes the SSH server.
82func (s *SSHServer) Close() error {
83 return s.srv.Close()
84}
85
86// Shutdown gracefully shuts down the SSH server.
87func (s *SSHServer) Shutdown(ctx context.Context) error {
88 return s.srv.Shutdown(ctx)
89}
90
91// PublicKeyAuthHandler handles public key authentication.
92func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
93 return s.cfg.Backend.AccessLevel("", pk) >= backend.ReadOnlyAccess
94}
95
96// KeyboardInteractiveHandler handles keyboard interactive authentication.
97func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
98 return s.cfg.Backend.AllowKeyless() && s.PublicKeyHandler(ctx, nil)
99}
100
101// Middleware adds Git server functionality to the ssh.Server. Repos are stored
102// in the specified repo directory. The provided Hooks implementation will be
103// checked for access on a per repo basis for a ssh.Session public key.
104// Hooks.Push and Hooks.Fetch will be called on successful completion of
105// their commands.
106func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
107 return func(sh ssh.Handler) ssh.Handler {
108 return func(s ssh.Session) {
109 func() {
110 cmd := s.Command()
111 if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
112 gc := cmd[0]
113 // repo should be in the form of "repo.git"
114 name := utils.SanitizeRepo(cmd[1])
115 pk := s.PublicKey()
116 access := cfg.Backend.AccessLevel(name, pk)
117 // git bare repositories should end in ".git"
118 // https://git-scm.com/docs/gitrepository-layout
119 repo := name + ".git"
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.Errorf("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}