cmd.go

  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}