1package cmd
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "strings"
8 "text/template"
9 "unicode"
10
11 "github.com/charmbracelet/log"
12 "github.com/charmbracelet/soft-serve/server/backend"
13 "github.com/charmbracelet/soft-serve/server/config"
14 "github.com/charmbracelet/soft-serve/server/errors"
15 "github.com/charmbracelet/soft-serve/server/utils"
16 "github.com/charmbracelet/ssh"
17 "github.com/charmbracelet/wish"
18 "github.com/prometheus/client_golang/prometheus"
19 "github.com/prometheus/client_golang/prometheus/promauto"
20 "github.com/spf13/cobra"
21)
22
23// sessionCtxKey is the key for the session in the context.
24var sessionCtxKey = &struct{ string }{"session"}
25
26var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
27 Namespace: "soft_serve",
28 Subsystem: "cli",
29 Name: "commands_total",
30 Help: "Total times each command was called",
31}, []string{"command"})
32
33var templateFuncs = template.FuncMap{
34 "trim": strings.TrimSpace,
35 "trimRightSpace": trimRightSpace,
36 "trimTrailingWhitespaces": trimRightSpace,
37 "rpad": rpad,
38 "gt": cobra.Gt,
39 "eq": cobra.Eq,
40}
41
42const (
43 usageTmpl = `Usage:{{if .Runnable}}
44 {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
45 {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
46
47Aliases:
48 {{.NameAndAliases}}{{end}}{{if .HasExample}}
49
50Examples:
51{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
52
53Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
54 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
55
56{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
57 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
58
59Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
60 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
61
62Flags:
63{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
64
65Global Flags:
66{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
67
68Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
69 {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
70
71Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information about a command.{{end}}
72`
73)
74
75func trimRightSpace(s string) string {
76 return strings.TrimRightFunc(s, unicode.IsSpace)
77}
78
79// rpad adds padding to the right of a string.
80func rpad(s string, padding int) string {
81 template := fmt.Sprintf("%%-%ds", padding)
82 return fmt.Sprintf(template, s)
83}
84
85func cmdName(args []string) string {
86 if len(args) == 0 {
87 return ""
88 }
89 return args[0]
90}
91
92// rootCommand is the root command for the server.
93func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command {
94 cliCommandCounter.WithLabelValues(cmdName(s.Command())).Inc()
95 rootCmd := &cobra.Command{
96 Short: "Soft Serve is a self-hostable Git server for the command line.",
97 SilenceUsage: true,
98 }
99
100 hostname := "localhost"
101 port := "23231"
102 url, err := url.Parse(cfg.SSH.PublicURL)
103 if err == nil {
104 hostname = url.Hostname()
105 port = url.Port()
106 }
107
108 sshCmd := "ssh"
109 if port != "" && port != "22" {
110 sshCmd += " -p " + port
111 }
112
113 sshCmd += " " + hostname
114 rootCmd.SetUsageTemplate(usageTmpl)
115 rootCmd.SetUsageFunc(func(c *cobra.Command) error {
116 t := template.New("usage")
117 t.Funcs(templateFuncs)
118 template.Must(t.Parse(c.UsageTemplate()))
119 return t.Execute(c.OutOrStderr(), struct {
120 *cobra.Command
121 SSHCommand string
122 }{
123 Command: c,
124 SSHCommand: sshCmd,
125 })
126 })
127 rootCmd.CompletionOptions.DisableDefaultCmd = true
128 rootCmd.AddCommand(
129 repoCommand(),
130 )
131
132 user, _ := cfg.Backend.UserByPublicKey(s.PublicKey())
133 isAdmin := isPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
134 if user != nil || isAdmin {
135 if isAdmin {
136 rootCmd.AddCommand(
137 settingsCommand(),
138 userCommand(),
139 )
140 }
141
142 rootCmd.AddCommand(
143 infoCommand(),
144 pubkeyCommand(),
145 setUsernameCommand(),
146 )
147 }
148
149 return rootCmd
150}
151
152func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
153 ctx := cmd.Context()
154 cfg := config.FromContext(ctx)
155 s := ctx.Value(sessionCtxKey).(ssh.Session)
156 return cfg, s
157}
158
159func checkIfReadable(cmd *cobra.Command, args []string) error {
160 var repo string
161 if len(args) > 0 {
162 repo = args[0]
163 }
164 cfg, s := fromContext(cmd)
165 rn := utils.SanitizeRepo(repo)
166 auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
167 if auth < backend.ReadOnlyAccess {
168 return errors.ErrUnauthorized
169 }
170 return nil
171}
172
173func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
174 for _, k := range cfg.AdminKeys() {
175 if backend.KeysEqual(pk, k) {
176 return true
177 }
178 }
179 return false
180}
181
182func checkIfAdmin(cmd *cobra.Command, _ []string) error {
183 cfg, s := fromContext(cmd)
184 if isPublicKeyAdmin(cfg, s.PublicKey()) {
185 return nil
186 }
187
188 user, _ := cfg.Backend.UserByPublicKey(s.PublicKey())
189 if user == nil {
190 return errors.ErrUnauthorized
191 }
192
193 if !user.IsAdmin() {
194 return errors.ErrUnauthorized
195 }
196
197 return nil
198}
199
200func checkIfCollab(cmd *cobra.Command, args []string) error {
201 var repo string
202 if len(args) > 0 {
203 repo = args[0]
204 }
205 cfg, s := fromContext(cmd)
206 rn := utils.SanitizeRepo(repo)
207 auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
208 if auth < backend.ReadWriteAccess {
209 return errors.ErrUnauthorized
210 }
211 return nil
212}
213
214// Middleware is the Soft Serve middleware that handles SSH commands.
215func Middleware(cfg *config.Config, logger *log.Logger) wish.Middleware {
216 return func(sh ssh.Handler) ssh.Handler {
217 return func(s ssh.Session) {
218 func() {
219 _, _, active := s.Pty()
220 if active {
221 return
222 }
223
224 // Ignore git server commands.
225 args := s.Command()
226 if len(args) > 0 {
227 if args[0] == "git-receive-pack" ||
228 args[0] == "git-upload-pack" ||
229 args[0] == "git-upload-archive" {
230 return
231 }
232 }
233
234 // Here we copy the server's config and replace the backend
235 // with a new one that uses the session's context.
236 var ctx context.Context = s.Context()
237 scfg := *cfg
238 cfg = &scfg
239 be := cfg.Backend.WithContext(ctx)
240 cfg.Backend = be
241 ctx = config.WithContext(ctx, cfg)
242 ctx = backend.WithContext(ctx, be)
243 ctx = context.WithValue(ctx, sessionCtxKey, s)
244
245 rootCmd := rootCommand(cfg, s)
246 rootCmd.SetArgs(args)
247 if len(args) == 0 {
248 // otherwise it'll default to os.Args, which is not what we want.
249 rootCmd.SetArgs([]string{"--help"})
250 }
251 rootCmd.SetIn(s)
252 rootCmd.SetOut(s)
253 rootCmd.CompletionOptions.DisableDefaultCmd = true
254 rootCmd.SetErr(s.Stderr())
255 if err := rootCmd.ExecuteContext(ctx); err != nil {
256 _ = s.Exit(1)
257 }
258 }()
259 sh(s)
260 }
261 }
262}