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