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