Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions activeterm/activeterm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
58 changes: 29 additions & 29 deletions bubbletea/tea.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,24 +32,24 @@ 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
// this program needs to work properly.
//
// 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
Expand All @@ -65,41 +65,41 @@ 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(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
}
p := bth(s)
if p == nil {
h(s)
program := handler(sess)
if program == nil {
next(sess)
return
}
ctx, cancel := context.WithCancel(s.Context())
ctx, cancel := context.WithCancel(sess.Context())
go func() {
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)
next(sess)
}
}
}
Expand All @@ -110,23 +110,23 @@ 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
}

// 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
Expand All @@ -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
}
Expand Down
28 changes: 28 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 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)
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. [Allocate a PTY](./pty)
1. [Running Bubble Tea, and executing another program on an allocated PTY](./wish-exec)
File renamed without changes.
25 changes: 14 additions & 11 deletions examples/pwd-banner/main.go → examples/banner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"net"
"os"
"os/signal"
"syscall"
Expand All @@ -20,43 +21,45 @@ import (

const (
host = "localhost"
port = 23234
port = "23234"
)

//go:embed banner.txt
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())
}),
wish.WithPasswordAuth(func(ctx ssh.Context, password string) bool {
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)
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.Error("Could not start server", "error", err)
done <- nil
}
}()
Expand All @@ -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)
}
}
49 changes: 31 additions & 18 deletions examples/bubbletea/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"net"
"os"
"os/signal"
"syscall"
Expand All @@ -17,34 +18,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"
)

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)
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.Error("Could not start server", "error", err)
done <- nil
}
}()
Expand All @@ -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)
}
}

Expand All @@ -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()}
}
Expand Down
Loading