Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Serving TUIs over SSH with Go

Serving TUIs over SSH with Go

Talk given at Gophercon LATAM 2025, showing how to create and serve a TUI over SSH using Go libraries such as Bubble Tea, Lipgloss, Wish, and more.
I also explained a little bit about terminals, ansi sequences, and the SSH protocol.

Tweet

More Decks by Carlos Alexandro Becker

Other Decks in Programming

Transcript

  1. Why? Isn't the world basically web apps now? ‣ Power

    users/developers love them ‣ Faster to make (vs Desktop app) ‣ Resource usage / performance ‣ Looks: novelty, aesthetics, nostalgia, "retro-futurism" 2 Carlos Becker - Gophercon Latam 2025
  2. $ whoami Carlos Alexandro Becker ‣ @caarlos0 most places ‣

    works @charmbracelet ‣ maintains @goreleaser ‣ caarlos0.dev 3 Carlos Becker - Gophercon Latam 2025
  3. Agenda ‣ CLIs x TUIs ‣ Terminals ‣ ANSI Sequences

    ‣ SSH ‣ Building a TUI ‣ Serving it over SSH 4 Carlos Becker - Gophercon Latam 2025
  4. Command Line Interfaces ‣ User gives input by typing commands

    ‣ Prompts, args, flags, env, configuration files, STDIN ‣ Usually non-interactive (y/N) ‣ Easy to script ‣ Examples: shells (bash, zsh, fish), git, coreutils, kubectl, docker 6 Carlos Becker - Gophercon Latam 2025
  5. Text-based User Interfaces ‣ Input via UI drawn with ASCII

    and Unicode symbols ‣ Interactive applications ‣ Might mimic elements from modern UIs: text inputs, buttons ‣ Not easy to script ‣ Examples: vim/nvim, htop, btop, tig, lazygit, lazydocker, k9s 7 Carlos Becker - Gophercon Latam 2025
  6. Good news though ‣ You can do both! ‣ --interactive/--non-interactive

    flags ‣ Check if STDIN/STDOUT is a TTY ‣ Get the best of both worlds :) 8 Carlos Becker - Gophercon Latam 2025
  7. Teletype Writers (TTYs) ‣ Basically a network-connected (serial) typewriter ‣

    Send text over the wire to other machine ‣ Get text back and prints it 10 Carlos Becker - Gophercon Latam 2025
  8. Video Terminals (VTs) ‣ Like a teletype ‣ Screen instead

    of paper 11 Carlos Becker - Gophercon Latam 2025
  9. Terminal Emulators ‣ XTerm was the first terminal emulator, based

    on VT102 ‣ Then it incorporated more features from other video terminals ‣ The Terminal application you use, whichever it might be, is a terminal emulator 12 Carlos Becker - Gophercon Latam 2025
  10. ANSI Sequences ‣ ANSI was the first standard ‣ Colors,

    Cursor movement, etc ‣ ECMA-48 is the international standardization of what began as ANSI ‣ ANSI was withdrawn in 1994 ‣ Everyone still calls them ANSI Sequences 14 Carlos Becker - Gophercon Latam 2025
  11. ANSI Sequences ‣ Usually starts with an ESC (\e, ^[,

    \033, or \x1b) ‣ Several types of sequences: ESC, CSI, OSC, DCS, APC ‣ New sequences are still being created printf '\e[6n' printf '\e[33mHello Gophercon\e[0m' printf '\e[=31;1u' printf '\e]0;Hello Gophercon\a' printf '\eP+q636F6C73\e\' 15 Carlos Becker - Gophercon Latam 2025
  12. Reading them ‣ You can make ANSI sequences human-readable with

    charm.sh/sequin ‣ Its built on top of charmbracelet/x/ansi 16 Carlos Becker - Gophercon Latam 2025
  13. ANSI Sequences in Go We can do the same with

    Lip Gloss and x/ansi import ( "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) fmt.Print(ansi.RequestCursorPositionReport) style :" lipgloss.NewStyle().Foreground(lipgloss.Yellow) lipgloss.Println(style.Render("Hello Gophercon!")) fmt.Print(ansi.KittyKeyboard(ansi.KittyAllFlags, 1)) fmt.Print(ansi.SetWindowTitle("Hello Gophercon!")) fmt.Print(ansi.RequestTermcap("cols")) 17 Carlos Becker - Gophercon Latam 2025
  14. SSH Server Client Server Client Initial connection Protocol versions and

    encryption algorithms exchange Encryption keys exchange (Diffie-Hellman) User authentication Session begins ... Session ends 20 Carlos Becker - Gophercon Latam 2025
  15. SSH Biggest Ls: ‣ Most non-technical people don't use or

    know what SSH is ‣ SSH doesn't send the hostname as part of the initial handshake ‣ i18n and l10n: SSH doesn't send TZ and LC* by default (-o SendEnv) ‣ Handshake is a bit slow (-o ControlPersist) ‣ man ssh_config 21 Carlos Becker - Gophercon Latam 2025
  16. SSH Biggest Ws: ‣ Widely available ‣ End-to-end encryption by

    default ‣ Authentication is a solved problem ‣ Can pipe from/into a host from your computer ‣ Can forward ports (which allow for some clever hacks) Friendly reminder: replace your RSA keys 22 Carlos Becker - Gophercon Latam 2025
  17. Bubble Tea A powerful little TUI framework. ‣ Elm-style: Init,

    Update, View ‣ Automatically downgrade colors based on user's terminal ‣ Many features built in: alt screens, mouse, resizing, background color detection, cursor, focus/blur, suspend/ resume, kitty keyboard, compositor (soon) ‣ Can be extended with Bubbles (components) and Huh (forms) 24 Carlos Becker - Gophercon Latam 2025
  18. Bubble Tea Init Cmd Msg Update View Input Output 25

    Carlos Becker - Gophercon Latam 2025
  19. Bubble Tea import tea "github.com/charmbracelet/bubbletea/v2" type model struct {} var

    _ tea.ViewModel = model{} 26 Carlos Becker - Gophercon Latam 2025
  20. Bubble Tea import "github.com/charmbracelet/bubbles/v2/stopwatch" type model struct { sw stopwatch.Model

    quitting bool } func (m model) Init() tea.Cmd { return m.sw.Start() } 27 Carlos Becker - Gophercon Latam 2025
  21. Bubble Tea func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

    switch msg.(type) { case tea.KeyPressMsg: m.quitting = true return m, tea.Quit } var cmd tea.Cmd m.sw, cmd = m.sw.Update(msg) return m, cmd } 28 Carlos Becker - Gophercon Latam 2025
  22. Bubble Tea import "github.com/charmbracelet/lipgloss/v2" var ( byeStyle = lipgloss.NewStyle(). Foreground(lipgloss.BrightBlack)

    swStyle = lipgloss.NewStyle(). Foreground(lipgloss.Yellow). Bold(true). Italic(true) ) func (m model) View() string { if m.quitting { return byeStyle.Render("Bye!\n") } return swStyle.Render(m.sw.View()) } 29 Carlos Becker - Gophercon Latam 2025
  23. Bubble Tea func main() { if _, err :" tea.NewProgram(newModel()).Run();

    err !$ nil { fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) } } func newModel() model { return model{ stopwatch.New(stopwatch.WithInterval(time.Second)), } } 30 Carlos Becker - Gophercon Latam 2025
  24. Bubble Tea Let's add support for suspend, a spinner, and

    a text input as well: import ( "github.com/charmbracelet/bubbles/v2/spinner" "github.com/charmbracelet/bubbles/v2/textinput" ) type model struct { sw stopwatch.Model sp spinner.Model ti textinput.Model quitting bool suspending bool } 32 Carlos Becker - Gophercon Latam 2025
  25. Bubble Tea func (m model) Init() tea.Cmd { return tea.Batch(

    m.sw.Start(), m.sp.Tick, ) } 33 Carlos Becker - Gophercon Latam 2025
  26. Bubble Tea func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

    switch msg :" msg.(type) { case tea.WindowSizeMsg: m.ti.SetWidth(msg.Width) case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "enter": m.quitting = true return m, tea.Quit case "ctrl+z": m.suspending = true return m, tea.Suspend } case tea.ResumeMsg: m.suspending = false } /% ..' 34 Carlos Becker - Gophercon Latam 2025
  27. Bubble Tea /" ..$ var cmd tea.Cmd var cmds []tea.Cmd

    m.sw, cmd = m.sw.Update(msg) cmds = append(cmds, cmd) m.sp, cmd = m.sp.Update(msg) cmds = append(cmds, cmd) m.ti, cmd = m.ti.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds..$) } 35 Carlos Becker - Gophercon Latam 2025
  28. Bubble Tea -var _ tea.ViewModel = model{} +var _ tea.CursorModel

    = model{} -func (m model) View() string { +func (m model) View() (string, *tea.Cursor) { 36 Carlos Becker - Gophercon Latam 2025
  29. Bubble Tea var spinStyle = lipgloss.NewStyle(). Foreground(lipgloss.BrightMagenta). PaddingLeft(1). PaddingRight(1) func

    (m model) View() (string, *tea.Cursor) { if m.quitting { return byeStyle.Render(fmt.Sprintf("Bye %s!\n", m.ti.Value())), nil } if m.suspending { return byeStyle.Render("See you soon!\n"), nil } return m.ti.View() + "\n" + lipgloss.JoinHorizontal( lipgloss.Left, spinStyle.Render(m.sp.View()), swStyle.Render(m.sw.View()), ), m.ti.Cursor() } 37 Carlos Becker - Gophercon Latam 2025
  30. Bubble Tea func newModel() model { ti :" textinput.New() ti.Placeholder

    = "What's your name?" ti.Focus() return model{ sw: stopwatch.New(stopwatch.WithInterval(time.Second)), sp: spinner.New(spinner.WithSpinner(spinner.Jump)), ti: ti, }} 38 Carlos Becker - Gophercon Latam 2025
  31. Bubbles ‣ filepicker ‣ help ‣ list ‣ paginator ‣

    progress ‣ spinner ‣ stopwatch ‣ table ‣ textarea ‣ textinput ‣ timer ‣ viewport 40 Carlos Becker - Gophercon Latam 2025
  32. Wish You can't just wish that... or can you? import

    ( "github.com/charmbracelet/log/v2" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish/v2" "github.com/charmbracelet/wish/v2/logging" btm "github.com/charmbracelet/wish/v2/bubbletea" ) 42 Carlos Becker - Gophercon Latam 2025
  33. Wish Creating a server: srv, err :" wish.NewServer( wish.WithAddress("localhost:23234"), wish.WithHostKeyPath("./.ssh/id_ed25519"),

    ssh.AllocatePty(), wish.WithMiddleware( btm.Middleware(func(ssh.Session) (tea.Model, []tea.ProgramOption) { return newModel(), []tea.ProgramOption{noSuspend} }), logging.StructuredMiddleware(), ), ) if err !$ nil { log.Fatal("Could not create wish server", "err", err) } 43 Carlos Becker - Gophercon Latam 2025
  34. Wish Do not suspend the server1: var noSuspend = tea.WithFilter(func(_

    tea.Model, msg tea.Msg) tea.Msg { if _, ok :" msg.(tea.SuspendMsg); ok { return tea.ResumeMsg{} } return msg }) 1 https://github.com/charmbracelet/wish/pull/457 44 Carlos Becker - Gophercon Latam 2025
  35. Wish Starting the server: log.Info("Starting", "addr", ":23234") if err =

    srv.ListenAndServe(); err !# nil &% !errors.Is(err, ssh.ErrServerClosed) { log.Fatal("Could not start server", "err", err) } 45 Carlos Becker - Gophercon Latam 2025
  36. Wish: public key auth carlos, _, _, _, _ :"

    ssh.ParseAuthorizedKey([]byte( "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL..%", )) srv, err :" wish.NewServer( /' ..% wish.WithPublicKeyAuth(func(_ ssh.Context, key ssh.PublicKey) bool { log.Info("public key") return ssh.KeysEqual(key, carlos) }), /' ..% ) 47 Carlos Becker - Gophercon Latam 2025
  37. Wish: password auth srv, err :" wish.NewServer( /$ ..& wish.WithPasswordAuth(func(_

    ssh.Context, password string) bool { log.Info("password") return password =( "how you turn this on" /$ }), /$ ..& ) 48 Carlos Becker - Gophercon Latam 2025
  38. Wish: keyboard interactive auth srv, err :" wish.NewServer( /$ ..&

    wish.WithKeyboardInteractiveAuth(func(_ ssh.Context, ch gossh.KeyboardInteractiveChallenge) bool { log.Info("keyboard-interactive") answers, err :" ch( "Welcome to my server!", "Please answer these questions:", []string{ "♦ How much is 2+3: ", "♦ Which editor is best, vim or emacs? ", "♦ Tell me your best secret: ", }, []bool{true, true, false}, ) if err !+ nil { return false } return len(answers) =- 3 &/ answers[0] =- "5" &/ answers[1] =- "vim" &/ answers[2] !+ "" }), /$ ..& ) 49 Carlos Becker - Gophercon Latam 2025
  39. Wish ‣ accesscontrol ‣ bubbletea ‣ comment ‣ elapsed ‣

    git ‣ logging ‣ ratelimiter ‣ recover ‣ scp ‣ promwish 51 Carlos Becker - Gophercon Latam 2025
  40. Next steps ‣ Access some cool SSH apps ‣ Learn

    more about ANSI sequences (charm.sh/sequin) ‣ Use more components from charm.sh/bubbles and charm.sh/huh ‣ Dig through charm.sh/wish and charm.sh/bubbletea examples ‣ Deploy it somewhere (e.g.: fly.io) 53 Carlos Becker - Gophercon Latam 2025
  41. Live examples $ ssh gophercon-talk.fly.dev # what we did today

    $ ssh git.charm.sh # soft serve git server $ ssh modchip.ai # ai over ssh $ ssh terminal.pet # keep it alive $ ssh terminal.coffee # buy coffee $ ssh -p2222 ssh.caarlos0.dev # confetti $ ssh -p2223 ssh.caarlos0.dev # fireworks $ TZ=America/Sao_Paulo ssh \ # current time in german -p23234 -oSendEnv=TZ \ ssh.caarlos0.dev 54 Carlos Becker - Gophercon Latam 2025