1package ssh
2
3import (
4 "fmt"
5 "os"
6 "time"
7
8 "github.com/charmbracelet/log"
9 "github.com/charmbracelet/soft-serve/pkg/backend"
10 "github.com/charmbracelet/soft-serve/pkg/config"
11 "github.com/charmbracelet/soft-serve/pkg/db"
12 "github.com/charmbracelet/soft-serve/pkg/proto"
13 "github.com/charmbracelet/soft-serve/pkg/ssh/cmd"
14 "github.com/charmbracelet/soft-serve/pkg/sshutils"
15 "github.com/charmbracelet/soft-serve/pkg/store"
16 "github.com/charmbracelet/ssh"
17 "github.com/charmbracelet/wish"
18 bm "github.com/charmbracelet/wish/bubbletea"
19 "github.com/muesli/termenv"
20 "github.com/prometheus/client_golang/prometheus"
21 "github.com/prometheus/client_golang/prometheus/promauto"
22 "github.com/spf13/cobra"
23 gossh "golang.org/x/crypto/ssh"
24)
25
26// ErrPermissionDenied is returned when a user is not allowed connect.
27var ErrPermissionDenied = fmt.Errorf("permission denied")
28
29// AuthenticationMiddleware handles authentication.
30func AuthenticationMiddleware(sh ssh.Handler) ssh.Handler {
31 return func(s ssh.Session) {
32 // XXX: The authentication key is set in the context but gossh doesn't
33 // validate the authentication. We need to verify that the _last_ key
34 // that was approved is the one that's being used.
35
36 pk := s.PublicKey()
37 if pk != nil {
38 // There is no public key stored in the context, public-key auth
39 // was never requested, skip
40 perms := s.Permissions().Permissions
41 if perms == nil {
42 wish.Fatalln(s, ErrPermissionDenied)
43 return
44 }
45
46 // Check if the key is the same as the one we have in context
47 fp := perms.Extensions["pubkey-fp"]
48 if fp != gossh.FingerprintSHA256(pk) {
49 wish.Fatalln(s, ErrPermissionDenied)
50 return
51 }
52 }
53
54 sh(s)
55 }
56}
57
58// ContextMiddleware adds the config, backend, and logger to the session context.
59func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler {
60 return func(sh ssh.Handler) ssh.Handler {
61 return func(s ssh.Session) {
62 s.Context().SetValue(sshutils.ContextKeySession, s)
63 s.Context().SetValue(config.ContextKey, cfg)
64 s.Context().SetValue(db.ContextKey, dbx)
65 s.Context().SetValue(store.ContextKey, datastore)
66 s.Context().SetValue(backend.ContextKey, be)
67 s.Context().SetValue(log.ContextKey, logger.WithPrefix("ssh"))
68 sh(s)
69 }
70 }
71}
72
73var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
74 Namespace: "soft_serve",
75 Subsystem: "cli",
76 Name: "commands_total",
77 Help: "Total times each command was called",
78}, []string{"command"})
79
80// CommandMiddleware handles git commands and CLI commands.
81// This middleware must be run after the ContextMiddleware.
82func CommandMiddleware(sh ssh.Handler) ssh.Handler {
83 return func(s ssh.Session) {
84 _, _, ptyReq := s.Pty()
85 if ptyReq {
86 sh(s)
87 return
88 }
89
90 ctx := s.Context()
91 cfg := config.FromContext(ctx)
92
93 renderer := bm.MakeRenderer(s)
94 if testrun, ok := os.LookupEnv("SOFT_SERVE_NO_COLOR"); ok && testrun == "1" {
95 // Disable colors when running tests.
96 renderer.SetColorProfile(termenv.Ascii)
97 }
98
99 args := s.Command()
100 cliCommandCounter.WithLabelValues(cmd.CommandName(args)).Inc()
101 rootCmd := &cobra.Command{
102 Short: "Soft Serve is a self-hostable Git server for the command line.",
103 SilenceUsage: true,
104 }
105 rootCmd.CompletionOptions.DisableDefaultCmd = true
106
107 rootCmd.SetUsageTemplate(cmd.UsageTemplate)
108 rootCmd.SetUsageFunc(cmd.UsageFunc)
109 rootCmd.AddCommand(
110 cmd.GitUploadPackCommand(),
111 cmd.GitUploadArchiveCommand(),
112 cmd.GitReceivePackCommand(),
113 cmd.RepoCommand(renderer),
114 cmd.SettingsCommand(),
115 cmd.UserCommand(),
116 cmd.InfoCommand(),
117 cmd.PubkeyCommand(),
118 cmd.SetUsernameCommand(),
119 cmd.JWTCommand(),
120 cmd.TokenCommand(),
121 )
122
123 if cfg.LFS.Enabled {
124 rootCmd.AddCommand(
125 cmd.GitLFSAuthenticateCommand(),
126 )
127
128 if cfg.LFS.SSHEnabled {
129 rootCmd.AddCommand(
130 cmd.GitLFSTransfer(),
131 )
132 }
133 }
134
135 rootCmd.SetArgs(args)
136 if len(args) == 0 {
137 // otherwise it'll default to os.Args, which is not what we want.
138 rootCmd.SetArgs([]string{"--help"})
139 }
140 rootCmd.SetIn(s)
141 rootCmd.SetOut(s)
142 rootCmd.SetErr(s.Stderr())
143 rootCmd.SetContext(ctx)
144
145 if err := rootCmd.ExecuteContext(ctx); err != nil {
146 s.Exit(1) // nolint: errcheck
147 return
148 }
149 }
150}
151
152// LoggingMiddleware logs the ssh connection and command.
153func LoggingMiddleware(sh ssh.Handler) ssh.Handler {
154 return func(s ssh.Session) {
155 ctx := s.Context()
156 logger := log.FromContext(ctx).WithPrefix("ssh")
157 ct := time.Now()
158 hpk := sshutils.MarshalAuthorizedKey(s.PublicKey())
159 ptyReq, _, isPty := s.Pty()
160 addr := s.RemoteAddr().String()
161 user := proto.UserFromContext(ctx)
162 logArgs := []interface{}{
163 "addr",
164 addr,
165 "cmd",
166 s.Command(),
167 }
168
169 if user != nil {
170 logArgs = append([]interface{}{
171 "username",
172 user.Username(),
173 }, logArgs...)
174 }
175
176 if isPty {
177 logArgs = []interface{}{
178 "term", ptyReq.Term,
179 "width", ptyReq.Window.Width,
180 "height", ptyReq.Window.Height,
181 }
182 }
183
184 if config.IsVerbose() {
185 logArgs = append(logArgs,
186 "key", hpk,
187 "envs", s.Environ(),
188 )
189 }
190
191 msg := fmt.Sprintf("user %q", s.User())
192 logger.Debug(msg+" connected", logArgs...)
193 sh(s)
194 logger.Debug(msg+" disconnected", append(logArgs, "duration", time.Since(ct))...)
195 }
196}