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 == "" || 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 ctx := s.Context()
63 ctx.SetValue(sshutils.ContextKeySession, s)
64 ctx.SetValue(config.ContextKey, cfg)
65 ctx.SetValue(db.ContextKey, dbx)
66 ctx.SetValue(store.ContextKey, datastore)
67 ctx.SetValue(backend.ContextKey, be)
68 ctx.SetValue(log.ContextKey, logger.WithPrefix("ssh"))
69 sh(s)
70 }
71 }
72}
73
74var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
75 Namespace: "soft_serve",
76 Subsystem: "cli",
77 Name: "commands_total",
78 Help: "Total times each command was called",
79}, []string{"command"})
80
81// CommandMiddleware handles git commands and CLI commands.
82// This middleware must be run after the ContextMiddleware.
83func CommandMiddleware(sh ssh.Handler) ssh.Handler {
84 return func(s ssh.Session) {
85 _, _, ptyReq := s.Pty()
86 if ptyReq {
87 sh(s)
88 return
89 }
90
91 ctx := s.Context()
92 cfg := config.FromContext(ctx)
93
94 renderer := bm.MakeRenderer(s)
95 if testrun, ok := os.LookupEnv("SOFT_SERVE_NO_COLOR"); ok && testrun == "1" {
96 // Disable colors when running tests.
97 renderer.SetColorProfile(termenv.Ascii)
98 }
99
100 args := s.Command()
101 cliCommandCounter.WithLabelValues(cmd.CommandName(args)).Inc()
102 rootCmd := &cobra.Command{
103 Short: "Soft Serve is a self-hostable Git server for the command line.",
104 SilenceUsage: true,
105 }
106 rootCmd.CompletionOptions.DisableDefaultCmd = true
107
108 rootCmd.SetUsageTemplate(cmd.UsageTemplate)
109 rootCmd.SetUsageFunc(cmd.UsageFunc)
110 rootCmd.AddCommand(
111 cmd.GitUploadPackCommand(),
112 cmd.GitUploadArchiveCommand(),
113 cmd.GitReceivePackCommand(),
114 cmd.RepoCommand(renderer),
115 cmd.SettingsCommand(),
116 cmd.UserCommand(),
117 cmd.InfoCommand(),
118 cmd.PubkeyCommand(),
119 cmd.SetUsernameCommand(),
120 cmd.JWTCommand(),
121 cmd.TokenCommand(),
122 )
123
124 if cfg.LFS.Enabled {
125 rootCmd.AddCommand(
126 cmd.GitLFSAuthenticateCommand(),
127 )
128
129 if cfg.LFS.SSHEnabled {
130 rootCmd.AddCommand(
131 cmd.GitLFSTransfer(),
132 )
133 }
134 }
135
136 rootCmd.SetArgs(args)
137 if len(args) == 0 {
138 // otherwise it'll default to os.Args, which is not what we want.
139 rootCmd.SetArgs([]string{"--help"})
140 }
141 rootCmd.SetIn(s)
142 rootCmd.SetOut(s)
143 rootCmd.SetErr(s.Stderr())
144 rootCmd.SetContext(ctx)
145
146 if err := rootCmd.ExecuteContext(ctx); err != nil {
147 s.Exit(1) // nolint: errcheck
148 return
149 }
150 }
151}
152
153// LoggingMiddleware logs the ssh connection and command.
154func LoggingMiddleware(sh ssh.Handler) ssh.Handler {
155 return func(s ssh.Session) {
156 ctx := s.Context()
157 logger := log.FromContext(ctx).WithPrefix("ssh")
158 ct := time.Now()
159 hpk := sshutils.MarshalAuthorizedKey(s.PublicKey())
160 ptyReq, _, isPty := s.Pty()
161 addr := s.RemoteAddr().String()
162 user := proto.UserFromContext(ctx)
163 logArgs := []interface{}{
164 "addr",
165 addr,
166 "cmd",
167 s.Command(),
168 }
169
170 if user != nil {
171 logArgs = append([]interface{}{
172 "username",
173 user.Username(),
174 }, logArgs...)
175 }
176
177 if isPty {
178 logArgs = []interface{}{
179 "term", ptyReq.Term,
180 "width", ptyReq.Window.Width,
181 "height", ptyReq.Window.Height,
182 }
183 }
184
185 if config.IsVerbose() {
186 logArgs = append(logArgs,
187 "key", hpk,
188 "envs", s.Environ(),
189 )
190 }
191
192 msg := fmt.Sprintf("user %q", s.User())
193 logger.Debug(msg+" connected", logArgs...)
194 sh(s)
195 logger.Debug(msg+" disconnected", append(logArgs, "duration", time.Since(ct))...)
196 }
197}