package main import ( "bytes" "context" "fmt" "io" "log" "math/rand" "net" "os" "path/filepath" "strconv" "time" "github.com/caarlos0/env/v11" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" "github.com/charmbracelet/wish/logging" "github.com/spf13/cobra" ) const ( maxNumber = 1000000000 timeout = 30 * time.Second ) type config struct { Port int `env:"PORT" envDefault:"1976"` Host string `env:"HOST" envDefault:"localhost"` GID int `env:"GID" envDefault:"0"` UID int `env:"UID" envDefault:"0"` KeyPath string `env:"KEY_PATH" envDefault:""` AuthorizedKeysPath string `env:"AUTHORIZED_KEYS_PATH"` } //nolint:wrapcheck var serveCmd = &cobra.Command{ Use: "serve", Short: "Start the VHS SSH server", RunE: func(cmd *cobra.Command, _ []string) error { var cfg config if err := env.ParseWithOptions(&cfg, env.Options{ Prefix: "VHS_", }); err != nil { return err } key := cfg.KeyPath if key == "" { key = filepath.Join(".ssh", "vhs_ed25519") } addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)) s, err := wish.NewServer( wish.WithAddress(addr), wish.WithHostKeyPath(key), func(s *ssh.Server) error { if cfg.AuthorizedKeysPath == "" { return nil } return wish.WithAuthorizedKeys(cfg.AuthorizedKeysPath)(s) }, wish.WithMiddleware( func(h ssh.Handler) ssh.Handler { return func(s ssh.Session) { // Request for vhs must be passed in through stdin, which // implies that there is no PTY. // // In the future, we should support PTY by providing a // Bubble Tea interface for VHS. // // Ideally, users can SSH into the server and get a // walk through of how to write a .tape file. _, _, isPty := s.Pty() if isPty { wish.Println(s, "PTY is not supported") _ = s.Exit(1) return } // Read stdin passed from the client. // This is the .tape file which contains the VHS commands. // // ssh vhs.charm.sh < demo.tape var b bytes.Buffer _, err := io.Copy(&b, s) if err != nil { wish.Errorln(s, err) _ = s.Exit(1) return } //nolint:gosec rand := rand.Int63n(maxNumber) tempFile := filepath.Join(os.TempDir(), fmt.Sprintf("vhs-%d", rand)) defer func() { _ = os.Remove(tempFile) }() errs := Evaluate(s.Context(), b.String(), s.Stderr(), func(v *VHS) { var gifOutput, mp4Output, webmOutput string switch { case v.Options.Video.Output.MP4 != "": tempFile += mp4 mp4Output = tempFile case v.Options.Video.Output.WebM != "": tempFile += webm webmOutput = tempFile default: tempFile += gif gifOutput = tempFile } v.Options.Video.Output.GIF = gifOutput v.Options.Video.Output.MP4 = mp4Output v.Options.Video.Output.WebM = webmOutput }) if len(errs) > 0 { printErrors(s.Stderr(), b.String(), errs) _ = s.Exit(1) } gif, _ := os.ReadFile(tempFile) wish.Print(s, string(gif)) h(s) } }, logging.Middleware(), ), ) if err != nil { return err } // listen log.Printf("Starting SSH server on %s", addr) ls, err := net.Listen("tcp", addr) if err != nil { return fmt.Errorf("failed to listen on %s: %w", addr, err) } // drop privileges gid, uid := cfg.GID, cfg.UID if gid != 0 && uid != 0 { log.Printf("Starting server with GID: %d, UID: %d", gid, uid) if err := dropUserPrivileges(gid, uid); err != nil { return err } } sch := make(chan error) go func() { defer close(sch) sch <- s.Serve(ls) }() <-cmd.Context().Done() log.Println("Stopping SSH server") ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if err := s.Shutdown(ctx); err != nil { return err } // wait for serve to finish return <-sch }, }