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

Serving TUIs over SSH with Go

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

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.

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