From 7f2b8fe2ea5e21b80ee90f51d61c97dfbd6879d7 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 5 Feb 2024 12:34:48 -0300 Subject: [PATCH 1/6] docs: improving examples and docs Signed-off-by: Carlos Alexandro Becker --- bubbletea/tea.go | 18 ++--- examples/README.md | 19 +++++ examples/{pwd-banner => banner}/banner.txt | 0 examples/{pwd-banner => banner}/main.go | 25 +++--- examples/bubbletea/main.go | 49 +++++++----- examples/cobra/main.go | 32 ++++---- examples/git/main.go | 88 +++++++++++----------- examples/identity/main.go | 54 ++++++++----- examples/multi-auth/main.go | 68 +++++++++++++---- examples/simple/main.go | 53 +++++++++---- options.go | 2 +- wish.go | 2 +- 12 files changed, 261 insertions(+), 149 deletions(-) create mode 100644 examples/README.md rename examples/{pwd-banner => banner}/banner.txt (100%) rename examples/{pwd-banner => banner}/main.go (68%) diff --git a/bubbletea/tea.go b/bubbletea/tea.go index 80f14ec1..7b11841f 100644 --- a/bubbletea/tea.go +++ b/bubbletea/tea.go @@ -66,7 +66,7 @@ func MiddlewareWithColorProfile(bth Handler, p termenv.Profile) wish.Middleware // If the client's color profile has less colors than p, p will be forced. // Use with caution. func MiddlewareWithProgramHandler(bth ProgramHandler, p termenv.Profile) wish.Middleware { - return func(h ssh.Handler) ssh.Handler { + return func(parentHandler ssh.Handler) ssh.Handler { return func(s ssh.Session) { s.Context().SetValue(minColorProfileKey, p) _, windowChanges, ok := s.Pty() @@ -74,9 +74,9 @@ func MiddlewareWithProgramHandler(bth ProgramHandler, p termenv.Profile) wish.Mi wish.Fatalln(s, "no active terminal, skipping") return } - p := bth(s) - if p == nil { - h(s) + program := bth(s) + if program == nil { + parentHandler(s) return } ctx, cancel := context.WithCancel(s.Context()) @@ -84,22 +84,22 @@ func MiddlewareWithProgramHandler(bth ProgramHandler, p termenv.Profile) wish.Mi for { select { case <-ctx.Done(): - p.Quit() + program.Quit() return case w := <-windowChanges: - p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height}) + program.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height}) } } }() - if _, err := p.Run(); err != nil { + if _, err := program.Run(); err != nil { log.Error("app exit with error", "error", err) } // p.Kill() will force kill the program if it's still running, // and restore the terminal to its original state in case of a // tui crash - p.Kill() + program.Kill() cancel() - h(s) + parentHandler(s) } } } diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..bae2bda1 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,19 @@ +# Wish Examples + +We recommend you follow the examples in the following order: + +## Basics + +1. [Simple](./simple) +1. [Server banner and middleware](./banner) +1. [Identifying Users](./identity) +1. [Multiple authentication types](./multi-auth) + +## Making SSH apps + +1. [Using spf13/cobra](./cobra) +1. [Serving Bubble Tea apps](./bubbletea) + +## SCP, SFTP, and Git + +1. [Serving a Git repository](./git) diff --git a/examples/pwd-banner/banner.txt b/examples/banner/banner.txt similarity index 100% rename from examples/pwd-banner/banner.txt rename to examples/banner/banner.txt diff --git a/examples/pwd-banner/main.go b/examples/banner/main.go similarity index 68% rename from examples/pwd-banner/main.go rename to examples/banner/main.go index 5f36abd6..554db0c0 100644 --- a/examples/pwd-banner/main.go +++ b/examples/banner/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net" "os" "os/signal" "syscall" @@ -20,7 +21,7 @@ import ( const ( host = "localhost" - port = 23234 + port = "23234" ) //go:embed banner.txt @@ -28,8 +29,9 @@ var banner string func main() { s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), - wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), + // A banner is always shown, even before authentication. wish.WithBannerHandler(func(ctx ssh.Context) string { return fmt.Sprintf(banner, ctx.User()) }), @@ -37,18 +39,19 @@ func main() { return password == "asd123" }), wish.WithMiddleware( - func(h ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - wish.Println(s, "Hello, world!") - h(s) + func(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { + wish.Println(sess, fmt.Sprintf("Hello, %s!", sess.User())) + next(sess) } }, - elapsed.Middleware(), logging.Middleware(), + // This middleware prints the session duration before disconnecting. + elapsed.Middleware(), ), ) if err != nil { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) @@ -56,7 +59,7 @@ func main() { log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) done <- nil } }() @@ -66,6 +69,6 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) + log.Error("Could not stop server", "error", err) } } diff --git a/examples/bubbletea/main.go b/examples/bubbletea/main.go index 70cb7c7c..a87b75f1 100644 --- a/examples/bubbletea/main.go +++ b/examples/bubbletea/main.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "net" "os" "os/signal" "syscall" @@ -17,26 +18,28 @@ import ( "github.com/charmbracelet/log" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" - bm "github.com/charmbracelet/wish/bubbletea" - lm "github.com/charmbracelet/wish/logging" + "github.com/charmbracelet/wish/activeterm" + "github.com/charmbracelet/wish/bubbletea" + "github.com/charmbracelet/wish/logging" ) const ( host = "localhost" - port = 23234 + port = "23234" ) func main() { s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), - wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithMiddleware( - bm.Middleware(teaHandler), - lm.Middleware(), + bubbletea.Middleware(teaHandler), + activeterm.Middleware(), // Bubble Tea apps usually require a PTY. + logging.Middleware(), ), ) if err != nil { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) @@ -44,7 +47,7 @@ func main() { log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) done <- nil } }() @@ -54,7 +57,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) + log.Error("Could not stop server", "error", err) } } @@ -63,18 +66,28 @@ func main() { // pass it to the new model. You can also return tea.ProgramOptions (such as // tea.WithAltScreen) on a session by session basis. func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { - pty, _, active := s.Pty() - if !active { - wish.Fatalln(s, "no active terminal, skipping") - return nil, nil - } - renderer := bm.MakeRenderer(s) + // This should never fail, as we are using the activeterm middleware. + pty, _, _ := s.Pty() + + // When running a Bubble Tea app over SSH, you shouldn't use the default + // lipgloss.NewStyle function. + // That function will use the color profile from the os.Stdin, which is the + // server, not the client. + // We provide a MakeRenderer function in the bubbletea middleware package, + // so you can easily get the correct renderer for the current session, and + // use it to create the styles. + // The recommended way to use these styles is to then pass them down to + // your Bubble Tea model. + renderer := bubbletea.MakeRenderer(s) + txtStyle := renderer.NewStyle().Foreground(lipgloss.Color("10")) + quitStyle := renderer.NewStyle().Foreground(lipgloss.Color("8")) + m := model{ term: pty.Term, width: pty.Window.Width, height: pty.Window.Height, - txtStyle: renderer.NewStyle().Foreground(lipgloss.Color("10")), - quitStyle: renderer.NewStyle().Foreground(lipgloss.Color("8")), + txtStyle: txtStyle, + quitStyle: quitStyle, } return m, []tea.ProgramOption{tea.WithAltScreen()} } diff --git a/examples/cobra/main.go b/examples/cobra/main.go index eb14b95b..2d92a373 100644 --- a/examples/cobra/main.go +++ b/examples/cobra/main.go @@ -3,7 +3,7 @@ package main import ( "context" "errors" - "fmt" + "net" "os" "os/signal" "syscall" @@ -18,7 +18,7 @@ import ( const ( host = "localhost" - port = 23235 + port = "23235" ) func cmd() *cobra.Command { @@ -46,30 +46,32 @@ func cmd() *cobra.Command { func main() { s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), - wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithMiddleware( - func(h ssh.Handler) ssh.Handler { - return func(s ssh.Session) { + func(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { + // Here we wire our command's args and IO to the user + // session's rootCmd := cmd() - rootCmd.SetArgs(s.Command()) - rootCmd.SetIn(s) - rootCmd.SetOut(s) - rootCmd.SetErr(s.Stderr()) + rootCmd.SetArgs(sess.Command()) + rootCmd.SetIn(sess) + rootCmd.SetOut(sess) + rootCmd.SetErr(sess.Stderr()) rootCmd.CompletionOptions.DisableDefaultCmd = true if err := rootCmd.Execute(); err != nil { - _ = s.Exit(1) + _ = sess.Exit(1) return } - h(s) + next(sess) } }, logging.Middleware(), ), ) if err != nil { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) @@ -77,7 +79,7 @@ func main() { log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) done <- nil } }() @@ -87,6 +89,6 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) + log.Error("Could not stop server", "error", err) } } diff --git a/examples/git/main.go b/examples/git/main.go index d2078b24..82bca675 100644 --- a/examples/git/main.go +++ b/examples/git/main.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io/fs" + "net" "os" "os/signal" "syscall" @@ -16,57 +17,53 @@ import ( "github.com/charmbracelet/log" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" - gm "github.com/charmbracelet/wish/git" - lm "github.com/charmbracelet/wish/logging" + "github.com/charmbracelet/wish/git" + "github.com/charmbracelet/wish/logging" ) const ( - port = 23233 + port = "23233" host = "localhost" repoDir = ".repos" ) type app struct { - access gm.AccessLevel + access git.AccessLevel } -func (a app) AuthRepo(repo string, pk ssh.PublicKey) gm.AccessLevel { +func (a app) AuthRepo(string, ssh.PublicKey) git.AccessLevel { return a.access } -func (a app) Push(repo string, pk ssh.PublicKey) { +func (a app) Push(repo string, _ ssh.PublicKey) { log.Info("push", "repo", repo) } -func (a app) Fetch(repo string, pk ssh.PublicKey) { +func (a app) Fetch(repo string, _ ssh.PublicKey) { log.Info("fetch", "repo", repo) } -func passHandler(ctx ssh.Context, password string) bool { - return false -} - -func pkHandler(ctx ssh.Context, key ssh.PublicKey) bool { - return true -} - func main() { // A simple GitHooks implementation to allow global read write access. - a := app{gm.ReadWriteAccess} + a := app{git.ReadWriteAccess} s, err := wish.NewServer( - ssh.PublicKeyAuth(pkHandler), - ssh.PasswordAuth(passHandler), - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), - wish.WithHostKeyPath(".ssh/git_server_ed25519"), + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), + // Accept any public key. + ssh.PublicKeyAuth(func(ssh.Context, ssh.PublicKey) bool { return true }), + // Do not accept password auth. + ssh.PasswordAuth(func(ssh.Context, string) bool { return false }), wish.WithMiddleware( - gm.Middleware(repoDir, a), + // Setup the git middleware. + git.Middleware(repoDir, a), + // Adds a middleware to list all available repositories to the user. gitListMiddleware, - lm.Middleware(), + logging.Middleware(), ), ) if err != nil { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) @@ -74,7 +71,7 @@ func main() { log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) done <- nil } }() @@ -84,34 +81,37 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) + log.Error("Could not stop server", "error", err) } } // Normally we would use a Bubble Tea program for the TUI but for simplicity, // we'll just write a list of the pushed repos to the terminal and exit the ssh // session. -func gitListMiddleware(h ssh.Handler) ssh.Handler { - return func(s ssh.Session) { +func gitListMiddleware(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { // Git will have a command included so only run this if there are no // commands passed to ssh. - if len(s.Command()) == 0 { - des, err := os.ReadDir(repoDir) - if err != nil && err != fs.ErrNotExist { - log.Error("invalid repository", "error", err) - } - if len(des) > 0 { - fmt.Fprintf(s, "\n### Repo Menu ###\n\n") - } - for _, de := range des { - fmt.Fprintf(s, "• %s - ", de.Name()) - fmt.Fprintf(s, "git clone ssh://%s:%d/%s\n", host, port, de.Name()) - } - fmt.Fprintf(s, "\n\n### Add some repos! ###\n\n") - fmt.Fprintf(s, "> cd some_repo\n") - fmt.Fprintf(s, "> git remote add wish_test ssh://%s:%d/some_repo\n", host, port) - fmt.Fprintf(s, "> git push wish_test\n\n\n") + if len(sess.Command()) != 0 { + next(sess) + return + } + + dest, err := os.ReadDir(repoDir) + if err != nil && err != fs.ErrNotExist { + log.Error("Invalid repository", "error", err) + } + if len(dest) > 0 { + fmt.Fprintf(sess, "\n### Repo Menu ###\n\n") + } + for _, dir := range dest { + wish.Println(sess, fmt.Sprintf("• %s - ", dir.Name())) + wish.Println(sess, fmt.Sprintf("git clone ssh://%s/%s", net.JoinHostPort(host, port), dir.Name())) } - h(s) + wish.Printf(sess, "\n\n### Add some repos! ###\n\n") + wish.Printf(sess, "> cd some_repo\n") + wish.Printf(sess, "> git remote add wish_test ssh://%s/some_repo\n", net.JoinHostPort(host, port)) + wish.Printf(sess, "> git push wish_test\n\n\n") + next(sess) } } diff --git a/examples/identity/main.go b/examples/identity/main.go index ac9ae45d..03d10b21 100644 --- a/examples/identity/main.go +++ b/examples/identity/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net" "os" "os/signal" "syscall" @@ -17,36 +18,51 @@ import ( const ( host = "localhost" - port = 23234 + port = "23234" ) +var users = map[string]string{ + "Carlos": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILxWe2rXKoiO6W14LYPVfJKzRfJ1f3Jhzxrgjc/D4tU7", + // You can add add your name and public key here :) +} + func main() { s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), - wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), + // This will allow anyone to log in, as long as they have given an + // ed25519 public key. + // You can test this by doing something like: + // ssh -i ~/.ssh/id_ed25519 -p 23234 localhost + // ssh -i ~/.ssh/id_rsa -p 23234 localhost + // ssh -o PreferredAuthentications=password -p 23234 localhost wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { - return true + return key.Type() == "ssh-ed25519" }), wish.WithMiddleware( - logging.Middleware(), - func(h ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - carlos, _, _, _, _ := ssh.ParseAuthorizedKey( - []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILxWe2rXKoiO6W14LYPVfJKzRfJ1f3Jhzxrgjc/D4tU7"), - ) - switch { - case ssh.KeysEqual(s.PublicKey(), carlos): - wish.Println(s, "Hey Carlos!") - default: - wish.Println(s, "Hey, I don't know who you are!") + func(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { + // if the current session's user public key is one of the + // known users, we greet them and return. + for name, pubkey := range users { + parsed, _, _, _, _ := ssh.ParseAuthorizedKey( + []byte(pubkey), + ) + if ssh.KeysEqual(sess.PublicKey(), parsed) { + wish.Println(sess, fmt.Sprintf("Hey %s!", name)) + next(sess) + return + } } - h(s) + wish.Println(sess, "Hey, I don't know who you are!") + next(sess) } }, + logging.Middleware(), ), ) if err != nil { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) @@ -54,7 +70,7 @@ func main() { log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) done <- nil } }() @@ -64,6 +80,6 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) + log.Error("Could not stop server", "error", err) } } diff --git a/examples/multi-auth/main.go b/examples/multi-auth/main.go index cd04913d..85dca971 100644 --- a/examples/multi-auth/main.go +++ b/examples/multi-auth/main.go @@ -3,7 +3,7 @@ package main import ( "context" "errors" - "fmt" + "net" "os" "os/signal" "syscall" @@ -18,43 +18,81 @@ import ( const ( host = "localhost" - port = 23234 - carlosPubkey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILxWe2rXKoiO6W14LYPVfJKzRfJ1f3Jhzxrgjc/D4tU7" + port = "23234" validPassword = "asd123" ) +var users = map[string]string{ + "Carlos": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILxWe2rXKoiO6W14LYPVfJKzRfJ1f3Jhzxrgjc/D4tU7", + // You can add add your name and public key here :) +} + func main() { s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), - wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), + + // In this example, we'll have multiple possible authentication methods. + // The order of preference is defined by the user (via + // PreferredAuthentications), and if all of them fails, they aren't + // allowed in. + // + // You can SSH into the server like so: + // ssh -o PreferredAuthentications=none -p 23234 localhost + // ssh -o PreferredAuthentications=password -p 23234 localhost + // ssh -o PreferredAuthentications=public-key -p 23234 localhost + // ssh -o PreferredAuthentications=keyboard-interactive -p 23234 localhost + + // First, public-key authentication: wish.WithPublicKeyAuth(func(_ ssh.Context, key ssh.PublicKey) bool { log.Info("public-key") - carlos, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(carlosPubkey)) - return ssh.KeysEqual(carlos, key) + for _, pubkey := range users { + parsed, _, _, _, _ := ssh.ParseAuthorizedKey( + []byte(pubkey), + ) + if ssh.KeysEqual(key, parsed) { + return true + } + } + return false }), + + // Then, password. wish.WithPasswordAuth(func(_ ssh.Context, password string) bool { log.Info("password") return password == validPassword }), + + // Finally, keyboard-interactive, which you can use to ask the user to + // answer a challenge: wish.WithKeyboardInteractiveAuth(func(_ ssh.Context, challenger gossh.KeyboardInteractiveChallenge) bool { log.Info("keyboard-interactive") - answers, err := challenger("", "", []string{"how much is 2+3: "}, []bool{true}) + answers, err := challenger( + "", "", + []string{ + "♦ How much is 2+3: ", + "♦ Which editor is best, vim or emacs? ", + }, + []bool{true, true}, + ) if err != nil { return false } - return len(answers) == 1 && answers[0] == "5" + // here we check for the correct answers: + return len(answers) == 2 && answers[0] == "5" && answers[1] == "vim" }), + wish.WithMiddleware( logging.Middleware(), - func(h ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - wish.Println(s, "authorized!") + func(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { + wish.Println(sess, "Authorized!") } }, ), ) if err != nil { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) @@ -62,7 +100,7 @@ func main() { log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) done <- nil } }() @@ -72,6 +110,6 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) + log.Error("Could not stop server", "error", err) } } diff --git a/examples/simple/main.go b/examples/simple/main.go index 73f45e89..82dfbfc4 100644 --- a/examples/simple/main.go +++ b/examples/simple/main.go @@ -3,7 +3,7 @@ package main import ( "context" "errors" - "fmt" + "net" "os" "os/signal" "syscall" @@ -17,42 +17,63 @@ import ( const ( host = "localhost" - port = 23234 + port = "23234" ) func main() { - s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), - wish.WithHostKeyPath(".ssh/term_info_ed25519"), + srv, err := wish.NewServer( + // The address the server will listen to. + wish.WithAddress(net.JoinHostPort(host, port)), + + // The SSH server need its own keys, this will create a keypair in the + // given path if it doesn't exist yet. + // By default, it will create an ED25519 key. + wish.WithHostKeyPath(".ssh/id_ed25519"), + + // Middlewares do something on a ssh.Session, and then call the next + // middleware in the stack. wish.WithMiddleware( - func(h ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - wish.Println(s, "Hello, world!") - h(s) + func(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { + wish.Println(sess, "Hello, world!") + next(sess) } }, + + // The last item in the chain is the first to be called. logging.Middleware(), ), ) if err != nil { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) } + // Before starting our server, we create a channel and listen for some + // common interrupt signals. done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - log.Info("Starting SSH server", "host", host, "port", port) + go func() { - if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) + log.Info("Starting SSH server", "host", host, "port", port) + 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 } }() + // Here we wait for the done signal. + // When it arrives, we create a context and start the shutdown. <-done - log.Info("Stopping SSH server") + + // When we start the shutdown, the server will no longer accept new + // connections, but will wait as much as the given context allows for the + // active connections to finish. + // After the timeout, it shuts down anyway. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() - if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) + 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) } } diff --git a/options.go b/options.go index d541b682..0810f8bc 100644 --- a/options.go +++ b/options.go @@ -64,7 +64,7 @@ func WithMiddleware(mw ...Middleware) ssh.Option { } } -// WithHostKeyFile returns an ssh.Option that sets the path to the private. +// WithHostKeyFile returns an ssh.Option that sets the path to the private key. func WithHostKeyPath(path string) ssh.Option { if _, err := os.Stat(path); os.IsNotExist(err) { _, err := keygen.New(path, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite()) diff --git a/wish.go b/wish.go index dbfc8f24..5718ae4a 100644 --- a/wish.go +++ b/wish.go @@ -10,7 +10,7 @@ import ( // Middleware is a function that takes an ssh.Handler and returns an // ssh.Handler. Implementations should call the provided handler argument. -type Middleware func(ssh.Handler) ssh.Handler +type Middleware func(next ssh.Handler) ssh.Handler // NewServer is returns a default SSH server with the provided Middleware. A // new SSH key pair of type ed25519 will be created if one does not exist. By From 898fc1a65e76544623500aec2a96c17c787808fb Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 5 Feb 2024 13:02:57 -0300 Subject: [PATCH 2/6] refactor: renamed some vars Signed-off-by: Carlos Alexandro Becker --- bubbletea/tea.go | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/bubbletea/tea.go b/bubbletea/tea.go index 7b11841f..1b786d53 100644 --- a/bubbletea/tea.go +++ b/bubbletea/tea.go @@ -23,7 +23,7 @@ type BubbleTeaHandler = Handler // nolint: revive // Handler is the function Bubble Tea apps implement to hook into the // SSH Middleware. This will create a new tea.Program for every connection and // start it with the tea.ProgramOptions returned. -type Handler func(ssh.Session) (tea.Model, []tea.ProgramOption) +type Handler func(sess ssh.Session) (tea.Model, []tea.ProgramOption) // ProgramHandler is the function Bubble Tea apps implement to hook into the SSH // Middleware. This should return a new tea.Program. This handler is different @@ -32,15 +32,15 @@ type Handler func(ssh.Session) (tea.Model, []tea.ProgramOption) // // Make sure to set the tea.WithInput and tea.WithOutput to the ssh.Session // otherwise the program will not function properly. -type ProgramHandler func(ssh.Session) *tea.Program +type ProgramHandler func(sess ssh.Session) *tea.Program // Middleware takes a Handler and hooks the input and output for the // ssh.Session into the tea.Program. // // It also captures window resize events and sends them to the tea.Program // as tea.WindowSizeMsgs. -func Middleware(bth Handler) wish.Middleware { - return MiddlewareWithProgramHandler(newDefaultProgramHandler(bth), termenv.Ascii) +func Middleware(handler Handler) wish.Middleware { + return MiddlewareWithProgramHandler(newDefaultProgramHandler(handler), termenv.Ascii) } // MiddlewareWithColorProfile allows you to specify the minimum number of colors @@ -48,8 +48,8 @@ func Middleware(bth Handler) wish.Middleware { // // If the client's color profile has less colors than p, p will be forced. // Use with caution. -func MiddlewareWithColorProfile(bth Handler, p termenv.Profile) wish.Middleware { - return MiddlewareWithProgramHandler(newDefaultProgramHandler(bth), p) +func MiddlewareWithColorProfile(handler Handler, profile termenv.Profile) wish.Middleware { + return MiddlewareWithProgramHandler(newDefaultProgramHandler(handler), profile) } // MiddlewareWithProgramHandler allows you to specify the ProgramHandler to be @@ -65,21 +65,21 @@ func MiddlewareWithColorProfile(bth Handler, p termenv.Profile) wish.Middleware // // If the client's color profile has less colors than p, p will be forced. // Use with caution. -func MiddlewareWithProgramHandler(bth ProgramHandler, p termenv.Profile) wish.Middleware { - return func(parentHandler ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - s.Context().SetValue(minColorProfileKey, p) - _, windowChanges, ok := s.Pty() +func MiddlewareWithProgramHandler(handler ProgramHandler, profile termenv.Profile) wish.Middleware { + return func(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { + sess.Context().SetValue(minColorProfileKey, profile) + _, windowChanges, ok := sess.Pty() if !ok { - wish.Fatalln(s, "no active terminal, skipping") + wish.Fatalln(sess, "no active terminal, skipping") return } - program := bth(s) + program := handler(sess) if program == nil { - parentHandler(s) + next(sess) return } - ctx, cancel := context.WithCancel(s.Context()) + ctx, cancel := context.WithCancel(sess.Context()) go func() { for { select { @@ -99,7 +99,7 @@ func MiddlewareWithProgramHandler(bth ProgramHandler, p termenv.Profile) wish.Mi // tui crash program.Kill() cancel() - parentHandler(s) + next(sess) } } } @@ -110,14 +110,14 @@ var profileNames = [4]string{"TrueColor", "ANSI256", "ANSI", "Ascii"} // MakeRenderer returns a lipgloss renderer for the current session. // This function handle PTYs as well, and should be used to style your application. -func MakeRenderer(s ssh.Session) *lipgloss.Renderer { - cp, ok := s.Context().Value(minColorProfileKey).(termenv.Profile) +func MakeRenderer(sess ssh.Session) *lipgloss.Renderer { + cp, ok := sess.Context().Value(minColorProfileKey).(termenv.Profile) if !ok { cp = termenv.Ascii } - r := newRenderer(s) + r := newRenderer(sess) if r.ColorProfile() > cp { - wish.Printf(s, "Warning: Client's terminal is %q, forcing %q\r\n", profileNames[r.ColorProfile()], profileNames[cp]) + wish.Printf(sess, "Warning: Client's terminal is %q, forcing %q\r\n", profileNames[r.ColorProfile()], profileNames[cp]) r.SetColorProfile(cp) } return r @@ -125,8 +125,8 @@ func MakeRenderer(s ssh.Session) *lipgloss.Renderer { // MakeOptions returns the tea.WithInput and tea.WithOutput program options // taking into account possible Emulated or Allocated PTYs. -func MakeOptions(s ssh.Session) []tea.ProgramOption { - return makeOpts(s) +func MakeOptions(sess ssh.Session) []tea.ProgramOption { + return makeOpts(sess) } type sshEnviron []string @@ -148,9 +148,9 @@ func (e sshEnviron) Getenv(k string) string { return "" } -func newDefaultProgramHandler(bth Handler) ProgramHandler { +func newDefaultProgramHandler(handler Handler) ProgramHandler { return func(s ssh.Session) *tea.Program { - m, opts := bth(s) + m, opts := handler(s) if m == nil { return nil } From 15dff45504e16ca1d368e253e649bad7d09553e3 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 5 Feb 2024 15:50:22 -0300 Subject: [PATCH 3/6] docs: more examples Signed-off-by: Carlos Alexandro Becker --- activeterm/activeterm.go | 18 ++++---- examples/README.md | 9 ++++ examples/bubbleteaprogram/main.go | 23 +++++----- examples/forward/main.go | 21 ++++++---- examples/go.mod | 5 ++- examples/go.sum | 10 +++-- examples/multichat/main.go | 36 ++++++++-------- examples/pty/main.go | 70 +++++++++++++++++++++++++++++++ examples/scp/main.go | 18 ++++---- examples/wish-exec/main.go | 56 ++++++++++++++++++------- logging/logging.go | 18 ++++---- 11 files changed, 200 insertions(+), 84 deletions(-) create mode 100644 examples/pty/main.go diff --git a/activeterm/activeterm.go b/activeterm/activeterm.go index 8ed590c9..b66b3150 100644 --- a/activeterm/activeterm.go +++ b/activeterm/activeterm.go @@ -2,23 +2,21 @@ package activeterm import ( - "fmt" - "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" ) // Middleware will exit 1 connections trying with no active terminals. func Middleware() wish.Middleware { - return func(sh ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - _, _, active := s.Pty() - if !active { - fmt.Fprintln(s, "Requires an active PTY") - s.Exit(1) // nolint: errcheck - return // unreachable + return func(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { + _, _, active := sess.Pty() + if active { + next(sess) + return } - sh(s) + wish.Println(sess, "Requires an active PTY") + _ = sess.Exit(1) } } } diff --git a/examples/README.md b/examples/README.md index bae2bda1..1f908ae8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,7 +13,16 @@ We recommend you follow the examples in the following order: 1. [Using spf13/cobra](./cobra) 1. [Serving Bubble Tea apps](./bubbletea) +1. [Serving Bubble Tea programs](./bubbleteaprogram) +1. [Reverse Port Forwarding](./forward) +1. [Multichat](./multichat/ ## SCP, SFTP, and Git 1. [Serving a Git repository](./git) +1. [SCP and SFTP](./scp) + +## Pseudo Terminals + +1. [Simple PTY example](./pty) +1. [Running Bubble Tea, and executing another program on a PTY](./wish-exec) diff --git a/examples/bubbleteaprogram/main.go b/examples/bubbleteaprogram/main.go index 883ed5c6..d303838d 100644 --- a/examples/bubbleteaprogram/main.go +++ b/examples/bubbleteaprogram/main.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "net" "os" "os/signal" "syscall" @@ -16,27 +17,27 @@ import ( "github.com/charmbracelet/log" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" - bm "github.com/charmbracelet/wish/bubbletea" - lm "github.com/charmbracelet/wish/logging" + "github.com/charmbracelet/wish/bubbletea" + "github.com/charmbracelet/wish/logging" "github.com/muesli/termenv" ) const ( host = "localhost" - port = 23234 + port = "23234" ) func main() { s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), - wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithMiddleware( myCustomBubbleteaMiddleware(), - lm.Middleware(), + logging.Middleware(), ), ) if err != nil { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) @@ -44,7 +45,7 @@ func main() { log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) done <- nil } }() @@ -54,7 +55,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) + log.Error("Could not stop server", "error", err) } } @@ -83,9 +84,9 @@ func myCustomBubbleteaMiddleware() wish.Middleware { height: pty.Window.Height, time: time.Now(), } - return newProg(m, append(bm.MakeOptions(s), tea.WithAltScreen())...) + return newProg(m, append(bubbletea.MakeOptions(s), tea.WithAltScreen())...) } - return bm.MiddlewareWithProgramHandler(teaHandler, termenv.ANSI256) + return bubbletea.MiddlewareWithProgramHandler(teaHandler, termenv.ANSI256) } // Just a generic tea.Model to demo terminal information of ssh. diff --git a/examples/forward/main.go b/examples/forward/main.go index a40196c1..b3474377 100644 --- a/examples/forward/main.go +++ b/examples/forward/main.go @@ -3,7 +3,7 @@ package main import ( "context" "errors" - "fmt" + "net" "os" "os/signal" "syscall" @@ -17,16 +17,19 @@ import ( const ( host = "localhost" - port = 23234 + port = "23234" ) // example usage: ssh -N -R 23236:localhost:23235 -p 23234 localhost + func main() { + // Create a new SSH ForwardedTCPHandler. forwardHandler := &ssh.ForwardedTCPHandler{} s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), - wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), func(s *ssh.Server) error { + // Set the Reverse TCP Handler up: s.ReversePortForwardingCallback = func(_ ssh.Context, bindHost string, bindPort uint32) bool { log.Info("reverse port forwarding allowed", "host", bindHost, "port", bindPort) return true @@ -40,7 +43,9 @@ func main() { wish.WithMiddleware( func(h ssh.Handler) ssh.Handler { return func(s ssh.Session) { - wish.Println(s, "remote port forwarding available") + wish.Println(s, "Remote port forwarding available!") + wish.Println(s, "Try it with:") + wish.Println(s, " ssh -N -R 23236:localhost:23235 -p 23234 localhost") h(s) } }, @@ -48,7 +53,7 @@ func main() { ), ) if err != nil { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) @@ -56,7 +61,7 @@ func main() { log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) done <- nil } }() @@ -66,6 +71,6 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) + log.Error("Could not stop server", "error", err) } } diff --git a/examples/go.mod b/examples/go.mod index ab465db1..40572d60 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/log v0.3.1 github.com/charmbracelet/ssh v0.0.0-20240202115812-f4ab1009799a github.com/charmbracelet/wish v0.5.0 + github.com/charmbracelet/x/editor v0.0.0-20240202113029-6ff29cf0473e github.com/muesli/termenv v0.15.2 github.com/pkg/sftp v1.13.6 github.com/spf13/cobra v1.8.0 @@ -40,10 +41,10 @@ require ( github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect diff --git a/examples/go.sum b/examples/go.sum index f2ff1984..66d7c18a 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -25,6 +25,8 @@ github.com/charmbracelet/log v0.3.1 h1:TjuY4OBNbxmHWSwO3tosgqs5I3biyY8sQPny/eCMT github.com/charmbracelet/log v0.3.1/go.mod h1:OR4E1hutLsax3ZKpXbgUqPtTjQfrh1pG3zwHGWuuq8g= github.com/charmbracelet/ssh v0.0.0-20240202115812-f4ab1009799a h1:ryXQeBfu7DN77RFiKLa/VA9VRkMsinpkv4qYparR//k= github.com/charmbracelet/ssh v0.0.0-20240202115812-f4ab1009799a/go.mod h1:GPT/bjXsVDf5TKq2P1n4zl79ZnGwt2lWr19DomWm7zw= +github.com/charmbracelet/x/editor v0.0.0-20240202113029-6ff29cf0473e h1:tBDIREfNMslOK2t8haqiXRADCp/j4DLJBAIIg9N7jH8= +github.com/charmbracelet/x/editor v0.0.0-20240202113029-6ff29cf0473e/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA= github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651 h1:3RXpZWGWTOeVXCTv0Dnzxdv/MhNUkBfEcbaTY0zrTQI= github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/term v0.0.0-20240202113029-6ff29cf0473e h1:45T85zTqW/gN3FK5/JFM5Jk+LJkdP2gAfJcg8xE5lBs= @@ -74,15 +76,15 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= diff --git a/examples/multichat/main.go b/examples/multichat/main.go index 16e0ba8f..987a9be7 100644 --- a/examples/multichat/main.go +++ b/examples/multichat/main.go @@ -3,7 +3,7 @@ package main import ( "context" "fmt" - "log" + "net" "os" "os/signal" "strings" @@ -14,16 +14,18 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" - bm "github.com/charmbracelet/wish/bubbletea" - lm "github.com/charmbracelet/wish/logging" + "github.com/charmbracelet/wish/activeterm" + "github.com/charmbracelet/wish/bubbletea" + "github.com/charmbracelet/wish/logging" "github.com/muesli/termenv" ) const ( host = "localhost" - port = 23234 + port = "23234" ) // app contains a wish server and the list of running programs. @@ -42,15 +44,16 @@ func (a *app) send(msg tea.Msg) { func newApp() *app { a := new(app) s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), - wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithMiddleware( - bm.MiddlewareWithProgramHandler(a.ProgramHandler, termenv.ANSI256), - lm.Middleware(), + bubbletea.MiddlewareWithProgramHandler(a.ProgramHandler, termenv.ANSI256), + activeterm.Middleware(), + logging.Middleware(), ), ) if err != nil { - log.Fatalln(err) + log.Error("Could not start server", "error", err) } a.Server = s @@ -61,32 +64,29 @@ func (a *app) Start() { var err error done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - log.Printf("Starting SSH server on %s:%d", host, port) + log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = a.ListenAndServe(); err != nil { - log.Fatalln(err) + log.Error("Could not start server", "error", err) + done <- nil } }() <-done - log.Println("Stopping SSH server") + log.Info("Stopping SSH server") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := a.Shutdown(ctx); err != nil { - log.Fatalln(err) + log.Error("Could not stop server", "error", err) } } func (a *app) ProgramHandler(s ssh.Session) *tea.Program { - if _, _, active := s.Pty(); !active { - wish.Fatalln(s, "terminal is not active") - } - model := initialModel() model.app = a model.id = s.User() - p := tea.NewProgram(model, bm.MakeOptions(s)...) + p := tea.NewProgram(model, bubbletea.MakeOptions(s)...) a.progs = append(a.progs, p) return p diff --git a/examples/pty/main.go b/examples/pty/main.go new file mode 100644 index 00000000..c283b53b --- /dev/null +++ b/examples/pty/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "errors" + "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" +) + +const ( + host = "localhost" + port = "23234" +) + +func main() { + srv, err := wish.NewServer( + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), + + // Wish can allocate a PTY per user session. + ssh.AllocatePty(), + + wish.WithMiddleware( + func(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { + pty, _, _ := sess.Pty() + wish.Printf(sess, "Hello, world!\r\n") + wish.Printf(sess, "Term: "+pty.Term+"\r\n") + wish.Printf(sess, "PTY: "+pty.Slave.Name()+"\r\n") + next(sess) + } + }, + + activeterm.Middleware(), + logging.Middleware(), + ), + ) + 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", "host", host, "port", port) + if err = srv.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { + 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) + } +} diff --git a/examples/scp/main.go b/examples/scp/main.go index 0fe9c2a4..d782339e 100644 --- a/examples/scp/main.go +++ b/examples/scp/main.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "io/fs" + "net" "os" "os/signal" "path/filepath" @@ -23,22 +24,25 @@ import ( const ( host = "localhost" - port = 23235 + port = "23235" ) func main() { root, _ := filepath.Abs("./examples/scp/testdata") handler := scp.NewFileSystemHandler(root) s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), - wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), + + // setup the sftp subsystem wish.WithSubsystem("sftp", sftpSubsystem(root)), wish.WithMiddleware( + // setup the scp middleware scp.Middleware(handler, handler), ), ) if err != nil { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) @@ -46,7 +50,7 @@ func main() { log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) done <- nil } }() @@ -56,7 +60,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) + log.Error("Could not stop server", "error", err) } } @@ -80,7 +84,7 @@ func sftpSubsystem(root string) ssh.SubsystemHandler { // Example readonly handler implementation for sftp. // -// other example implementations: +// Other example implementations: // - https://github.com/gravitational/teleport/blob/f57dc2fe2a9900ec198779aae747ac4f833b278d/tool/teleport/common/sftp.go // - https://github.com/minio/minio/blob/c66c5828eacb4a7fa9a49b4c890c77dd8684b171/cmd/sftp-server.go type sftpHandler struct { diff --git a/examples/wish-exec/main.go b/examples/wish-exec/main.go index 7bb7437a..90ba571b 100644 --- a/examples/wish-exec/main.go +++ b/examples/wish-exec/main.go @@ -3,7 +3,7 @@ package main import ( "context" "errors" - "fmt" + "net" "os" "os/signal" "runtime" @@ -15,26 +15,36 @@ import ( "github.com/charmbracelet/log" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" - bm "github.com/charmbracelet/wish/bubbletea" - lm "github.com/charmbracelet/wish/logging" + "github.com/charmbracelet/wish/activeterm" + "github.com/charmbracelet/wish/bubbletea" + "github.com/charmbracelet/wish/logging" + "github.com/charmbracelet/x/editor" ) const ( host = "localhost" - port = 23235 + port = "23234" ) func main() { s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), + wish.WithAddress(net.JoinHostPort(host, port)), + + // Allocate a pty. + // This creates a pseudoconsole on windows, compatibility is limited in + // that case, see the open issues for more details. ssh.AllocatePty(), wish.WithMiddleware( - bm.Middleware(teaHandler), - lm.Middleware(), + // run our Bubble Tea handler + bubbletea.Middleware(teaHandler), + + // ensure the user has requested a tty + activeterm.Middleware(), + logging.Middleware(), ), ) if err != nil { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) @@ -42,7 +52,7 @@ func main() { log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) + log.Error("Could not start server", "error", err) done <- nil } }() @@ -52,12 +62,16 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) + log.Error("Could not stop server", "error", err) } } func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { - renderer := bm.MakeRenderer(s) + // Create a lipgloss.Renderer for the session + renderer := bubbletea.MakeRenderer(s) + // Set up the model with the current session and styles. + // We'll use the session to call wish.Command, which makes it compatible + // with tea.Command. m := model{ sess: s, style: renderer.NewStyle().Foreground(lipgloss.Color("8")), @@ -82,18 +96,30 @@ type cmdFinishedMsg struct{ err error } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - // PS: the execs won't work on windows. switch msg.String() { case "e": - c := wish.Command(m.sess, "vim", "file.txt") - cmd := tea.Exec(c, func(err error) tea.Msg { + // Open file.txt in the default editor. + edit, err := editor.Cmd("wish", "file.txt") + if err != nil { + m.err = err + return m, nil + } + // Creates a wish.Cmd from the exec.Cmd + wishCmd := wish.Command(m.sess, edit.Path, edit.Args...) + // Runs the cmd through Bubble Tea. + // Bubble Tea should handle the IO to the program, and get it back + // once the program quits. + cmd := tea.Exec(wishCmd, func(err error) tea.Msg { if err != nil { - log.Error("vim finished", "error", err) + log.Error("editor finished", "error", err) } return cmdFinishedMsg{err: err} }) return m, cmd case "s": + // We can also execute a shell and give it over to the user. + // Note that this session won't have control, so it can't run tasks + // in background, suspend, etc. c := wish.Command(m.sess, "bash", "-im") if runtime.GOOS == "windows" { c = wish.Command(m.sess, "powershell") diff --git a/logging/logging.go b/logging/logging.go index 8bd69b81..97f916ff 100644 --- a/logging/logging.go +++ b/logging/logging.go @@ -28,25 +28,25 @@ type Logger interface { // auth was public key based. Disconnect will log the remote address and // connection duration. func MiddlewareWithLogger(logger Logger) wish.Middleware { - return func(sh ssh.Handler) ssh.Handler { - return func(s ssh.Session) { + return func(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { ct := time.Now() - hpk := s.PublicKey() != nil - pty, _, _ := s.Pty() + hpk := sess.PublicKey() != nil + pty, _, _ := sess.Pty() logger.Printf( "%s connect %s %v %v %s %v %v", - s.User(), - s.RemoteAddr().String(), + sess.User(), + sess.RemoteAddr().String(), hpk, - s.Command(), + sess.Command(), pty.Term, pty.Window.Width, pty.Window.Height, ) - sh(s) + next(sess) logger.Printf( "%s disconnect %s\n", - s.RemoteAddr().String(), + sess.RemoteAddr().String(), time.Since(ct), ) } From e86fa8f1c15c6dacd7e2d57bc6239e9b2259bde2 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 5 Feb 2024 15:51:23 -0300 Subject: [PATCH 4/6] chore: typo --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 1f908ae8..cf300c2f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,7 +15,7 @@ We recommend you follow the examples in the following order: 1. [Serving Bubble Tea apps](./bubbletea) 1. [Serving Bubble Tea programs](./bubbleteaprogram) 1. [Reverse Port Forwarding](./forward) -1. [Multichat](./multichat/ +1. [Multichat](./multichat) ## SCP, SFTP, and Git From 9f765dfa11d4d79d54a9cb59d79c1cf8f60c8331 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 5 Feb 2024 15:52:12 -0300 Subject: [PATCH 5/6] docs: wording --- examples/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/README.md b/examples/README.md index cf300c2f..79b48b39 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,5 +24,5 @@ We recommend you follow the examples in the following order: ## Pseudo Terminals -1. [Simple PTY example](./pty) -1. [Running Bubble Tea, and executing another program on a PTY](./wish-exec) +1. [Allocate a PTY](./pty) +1. [Running Bubble Tea, and executing another program on an allocated PTY](./wish-exec) From 2a5eafcdce4302d73c0084fddea1a9cfda4e7ac8 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 5 Feb 2024 20:57:11 -0300 Subject: [PATCH 6/6] Apply suggestions from code review Co-authored-by: Ayman Bagabas --- examples/pty/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/pty/main.go b/examples/pty/main.go index c283b53b..88abef07 100644 --- a/examples/pty/main.go +++ b/examples/pty/main.go @@ -1,3 +1,5 @@ +//go:build !windows + package main import (