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