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 // Logging middleware.
44 lm.MiddlewareWithLogger(logger),
45 ),
46 }
47 s.Server, err = wish.NewServer(
48 ssh.PublicKeyAuth(s.PublicKeyHandler),
49 ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
50 wish.WithAddress(cfg.SSH.ListenAddr),
51 wish.WithHostKeyPath(cfg.SSH.KeyPath),
52 wish.WithMiddleware(mw...),
53 )
54 if err != nil {
55 return nil, err
56 }
57
58 if cfg.SSH.MaxTimeout > 0 {
59 s.Server.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second
60 }
61 if cfg.SSH.IdleTimeout > 0 {
62 s.Server.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second
63 }
64
65 return s, nil
66}
67
68// PublicKeyAuthHandler handles public key authentication.
69func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
70 return s.cfg.Access.AccessLevel("", pk) > backend.NoAccess
71}
72
73// KeyboardInteractiveHandler handles keyboard interactive authentication.
74func (s *SSHServer) KeyboardInteractiveHandler(_ ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
75 return true
76}
77
78// Middleware adds Git server functionality to the ssh.Server. Repos are stored
79// in the specified repo directory. The provided Hooks implementation will be
80// checked for access on a per repo basis for a ssh.Session public key.
81// Hooks.Push and Hooks.Fetch will be called on successful completion of
82// their commands.
83func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
84 return func(sh ssh.Handler) ssh.Handler {
85 return func(s ssh.Session) {
86 func() {
87 cmd := s.Command()
88 if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
89 gc := cmd[0]
90 // repo should be in the form of "repo.git"
91 repo := sanitizeRepoName(cmd[1])
92 name := repo
93 if strings.Contains(repo, "/") {
94 log.Printf("invalid repo: %s", repo)
95 sshFatal(s, fmt.Errorf("%s: %s", ErrInvalidRepo, "user repos not supported"))
96 return
97 }
98 pk := s.PublicKey()
99 access := cfg.Access.AccessLevel(name, pk)
100 // git bare repositories should end in ".git"
101 // https://git-scm.com/docs/gitrepository-layout
102 repo = strings.TrimSuffix(repo, ".git") + ".git"
103 // FIXME: determine repositories path
104 repoDir := filepath.Join(cfg.DataPath, "repos", repo)
105 switch gc {
106 case ReceivePackBin:
107 if access < backend.ReadWriteAccess {
108 sshFatal(s, ErrNotAuthed)
109 return
110 }
111 if _, err := cfg.Backend.Repository(name); err != nil {
112 if _, err := cfg.Backend.CreateRepository(name, false); err != nil {
113 log.Printf("failed to create repo: %s", err)
114 sshFatal(s, err)
115 return
116 }
117 }
118 if err := ReceivePack(s, s, s.Stderr(), repoDir); err != nil {
119 sshFatal(s, ErrSystemMalfunction)
120 }
121 return
122 case UploadPackBin, UploadArchiveBin:
123 if access < backend.ReadOnlyAccess {
124 sshFatal(s, ErrNotAuthed)
125 return
126 }
127 gitPack := UploadPack
128 if gc == UploadArchiveBin {
129 gitPack = UploadArchive
130 }
131 err := gitPack(s, s, s.Stderr(), repoDir)
132 if errors.Is(err, ErrInvalidRepo) {
133 sshFatal(s, ErrInvalidRepo)
134 } else if err != nil {
135 sshFatal(s, ErrSystemMalfunction)
136 }
137 }
138 }
139 }()
140 sh(s)
141 }
142 }
143}
144
145// sshFatal prints to the session's STDOUT as a git response and exit 1.
146func sshFatal(s ssh.Session, v ...interface{}) {
147 WritePktline(s, v...)
148 s.Exit(1) // nolint: errcheck
149}
150
151func sanitizeRepoName(repo string) string {
152 repo = strings.TrimPrefix(repo, "/")
153 repo = filepath.Clean(repo)
154 repo = strings.TrimSuffix(repo, ".git")
155 return repo
156}