Overview of Wish
mainopenssh-server and does not provide a default shell, making it safe for hosting specific application services.repository·main·Indexed 26 days ago
https://github.com/charmbracelet/wishA Go library for building custom SSH servers. Wish provides a middleware-based framework to securely serve TUIs via Bubble Tea, Git servers, and other network services over SSH without requiring openssh-server. It includes built-in support for public key authentication, connection logging, access control, and PTY management.
openssh-server and does not provide a default shell, making it safe for hosting specific application services.To run a Wish application as a background service using systemd, create a service unit file (e.g., /etc/systemd/system/myapp.service).
useradd --system --user-group --create-home myapp[Unit]
Description=My App
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/home/myapp/
ExecStart=/usr/bin/myapp
Restart=on-failure
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl start myappTo upgrade a Wish application to v2, follow these primary steps:
github.com/charmbracelet/* to the charm.land vanity domain with v2 suffixes.string in View() to returning a tea.View struct.bubbletea.MakeRenderer() as color profiles are now handled automatically via messages.tea.WithAltScreen() from the teaHandler return values into the tea.View struct configuration.To avoid polluting your ~/.ssh/known_hosts file with localhost entries while developing Wish applications locally, add the following to your ~/.ssh/config:
Host localhost
UserKnownHostsFile /dev/nullIf you require access to the client's environment variables before the Bubble Tea Init() method is called, you must manually extract them from the ssh.Session within your handler and pass them into your model's constructor.
Iterate over s.Environ() from the ssh.Session to build a map of the client's environment variables.
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
// Get client environment variables from the SSH session
env := make(map[string]string)
for _, e := range s.Environ() {
parts := strings.SplitN(e, "=", 2)
if len(parts) == 2 {
env[parts[0]] = parts[1]
}
}
m := model{
env: env, // Pass to model
}
return m, bubbletea.MakeOptions(s)
}
type model struct {
env map[string]string
}
func (m model) View() tea.View {
// Access client's environment
term := m.env["TERM"]
lang := m.env["LANG"]
user := m.env["USER"]
return tea.NewView(fmt.Sprintf("Your TERM: %s", term))
}When building SSH applications with Wish, you must access the SSH client's environment variables rather than using os.Getenv(), as os.Getenv() returns the server's environment.
To ensure the client's environment is available to your Bubble Tea model, use bubbletea.MakeOptions(s) in your handler. This automatically passes the client's environment to the model via a tea.EnvMsg.
tea.EnvMsgBubble Tea v2 sends a tea.EnvMsg containing the client's environment. You can intercept this in your model's Update method to access specific variables like TERM, LANG, or USER using the .Getenv() method.
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return model{}, bubbletea.MakeOptions(s) // Passes client environment
}
type model struct {
envMsg tea.EnvMsg
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.EnvMsg:
m.envMsg = msg
// Access specific CLIENT variables
term := msg.Getenv("TERM")
lang := msg.Getenv("LANG")
user := msg.Getenv("USER")
fmt.Printf("Client TERM: %s\n", term)
}
return m, nil
}Bubble Tea v2 introduces more granular message types for input handling:
tea.KeyMsg is replaced by tea.KeyPressMsg and tea.KeyReleaseMsg. Note that key strings are now full names (e.g., "space" instead of " ").tea.MouseClickMsg, tea.MouseWheelMsg, and tea.MouseMotionMsg.tea.PasteMsg to handle clipboard paste events.tea.SetClipboard(string) to write to the clipboard and tea.ReadClipboard() to read from it.// Key Press Example
case tea.KeyPressMsg:
switch msg.String() {
case "space":
// handle space
case "ctrl+c":
return m, tea.SetClipboard("Copied text")
}
// Paste Example
case tea.PasteMsg:
m.text += msg.Content
// Mouse Click Example
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
// handle click
}The git middleware adds Git server functionality to your SSH server. It supports repository creation upon the initial push and allows for custom public key-based authentication.
Requirement: The git binary must be installed on the host server.
bubbletea middleware allows you to serve Bubble Tea applications over SSH. It automatically creates a unique tea.Program for each SSH session, connecting the SSH pty input and output. It also natively handles client window dimensions and resize messages.Instead of querying the terminal renderer during initialization, listen for specific messages in your Update loop to react to terminal changes.
tea.BackgroundColorMsg. Use msg.IsDark() to determine the theme.tea.ColorProfileMsg to get the profile string (e.g., "TrueColor", "ANSI256", "ANSI").To trigger the initial check, return tea.RequestBackgroundColor from your Init() method.
func (m model) Init() tea.Cmd {
return tea.RequestBackgroundColor
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.BackgroundColorMsg:
if msg.IsDark() {
m.bg = "dark"
} else {
m.bg = "light"
}
case tea.ColorProfileMsg:
m.profile = msg.String()
}
return m, nil
}Wish provides several middlewares for managing connections:
logging: Provides basic connection logging, including remote address, invoked command, TERM setting, window dimensions, and whether authentication was public key-based. It also logs disconnects with the connection duration.activeterm: Restricts access to only allow connections that have an active terminal connected.accesscontrol: Allows you to specify which commands are permitted.The middleware API has been simplified and updated:
MakeRenderer(), MiddlewareWithColorProfile(), and QueryTerminalFilter are no longer available.charm.land/wish/v2.Middleware type.MiddlewareWithProgramHandler no longer requires a termenv.Profile argument; it now only takes the ProgramHandler.Logging Middleware: When using logging.StructuredMiddlewareWithLogger, ensure you are importing charm.land/log/v2 for the log.Logger type.
// Before
func MiddlewareWithProgramHandler(
handler ProgramHandler,
profile termenv.Profile,
) wish.Middleware
// After
func MiddlewareWithProgramHandler(handler ProgramHandler) wish.Middleware