package serve import ( "context" "errors" "fmt" "net" "os" "os/signal" "syscall" "time" "github.com/charmbracelet/log" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" "github.com/charmbracelet/wish/activeterm" "github.com/charmbracelet/wish/logging" gossh "golang.org/x/crypto/ssh" ) // Setup default logger to append or create a new log file `log` in the current // working directory containing all the logs reported by the ssh server. func setupLogging() { file, err := os.OpenFile("log", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) if err != nil { panic(fmt.Errorf("Could not create or open log file. err: %w", err)) } log.SetDefault(log.NewWithOptions(file, log.Options{ReportTimestamp: true})) } // Configure ssh server with the provided arguments. An empty `users` map // will disable user authentication. At least one user is required for user // authentication via ssh-public keys. Each map entry has a pair of *username* // and *pub-key value*. The ssh server will serve every authenticated user (or // all if no user is given) the application `name` with the provided arguments // `args` as the ssh session. The ssh session will always allocate an pty // (necessary for tui applications). func setupSshServer(host string, port string, host_key_path string, users map[string]string, name string, args []string) (*ssh.Server, error) { return wish.NewServer( wish.WithAddress(net.JoinHostPort(host, port)), wish.WithHostKeyPath(host_key_path), wish.WithPublicKeyAuth(func(_ ssh.Context, key ssh.PublicKey) bool { if len(users) == 0 { // no users provided, meaning there is no user authentication, everyone is allowed to connect return true } for _, pubkey := range users { parsed, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(pubkey)) if ssh.KeysEqual(key, parsed) { return true } } return false }), wish.WithKeyboardInteractiveAuth(func(_ ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { if len(users) == 0 { // no users provided, meaning there is no user authentication, everyone is allowed to connect return true } // NOTE interactive logins through keyboard challenges shall not be allowed; use // ssh public-private key-pairs instead for limited access return false }), ssh.AllocatePty(), wish.WithMiddleware( func(next ssh.Handler) ssh.Handler { return func(s ssh.Session) { provided_args := s.Command() if len(provided_args) > 0 { args = provided_args } cmd := wish.Command(s, name, args...) if err := cmd.Run(); err != nil { wish.Fatalln(s, err) } next(s) } }, activeterm.Middleware(), logging.Middleware(), ), ) } // Serve an ssh application using the provided arguments. func Serve(host string, port string, host_key_path string, users map[string]string, name string, args []string) { setupLogging() srv, err := setupSshServer(host, port, host_key_path, users, name, args) if err != nil { log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { log.Info("Starting SSH server...") if err = srv.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { // We ignore ErrServerClosed because it is expected. log.Error("Could not start server", "error", err) done <- nil } }() <-done ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() log.Info("Stopping SSH server...") if err := srv.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { log.Error("Could not stop server", "error", err) } }