Slide 1

Slide 1 text

Serving TUIs over SSH using Go 1 Carlos Becker - Gophercon Latam 2025

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

$ whoami Carlos Alexandro Becker ‣ @caarlos0 most places ‣ works @charmbracelet ‣ maintains @goreleaser ‣ caarlos0.dev 3 Carlos Becker - Gophercon Latam 2025

Slide 4

Slide 4 text

Agenda ‣ CLIs x TUIs ‣ Terminals ‣ ANSI Sequences ‣ SSH ‣ Building a TUI ‣ Serving it over SSH 4 Carlos Becker - Gophercon Latam 2025

Slide 5

Slide 5 text

CLIs x TUIs 5 Carlos Becker - Gophercon Latam 2025

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

Terminals 9 Carlos Becker - Gophercon Latam 2025

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

Video Terminals (VTs) ‣ Like a teletype ‣ Screen instead of paper 11 Carlos Becker - Gophercon Latam 2025

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

ANSI Sequences 13 Carlos Becker - Gophercon Latam 2025

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

18 Carlos Becker - Gophercon Latam 2025

Slide 19

Slide 19 text

SSH 19 Carlos Becker - Gophercon Latam 2025

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

Building a TUI 23 Carlos Becker - Gophercon Latam 2025

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

Bubble Tea Init Cmd Msg Update View Input Output 25 Carlos Becker - Gophercon Latam 2025

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

31 Carlos Becker - Gophercon Latam 2025

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

Bubble Tea func (m model) Init() tea.Cmd { return tea.Batch( m.sw.Start(), m.sp.Tick, ) } 33 Carlos Becker - Gophercon Latam 2025

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

39 Carlos Becker - Gophercon Latam 2025

Slide 40

Slide 40 text

Bubbles ‣ filepicker ‣ help ‣ list ‣ paginator ‣ progress ‣ spinner ‣ stopwatch ‣ table ‣ textarea ‣ textinput ‣ timer ‣ viewport 40 Carlos Becker - Gophercon Latam 2025

Slide 41

Slide 41 text

Serving it over SSH 41 Carlos Becker - Gophercon Latam 2025

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

46 Carlos Becker - Gophercon Latam 2025

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

50 Carlos Becker - Gophercon Latam 2025

Slide 51

Slide 51 text

Wish ‣ accesscontrol ‣ bubbletea ‣ comment ‣ elapsed ‣ git ‣ logging ‣ ratelimiter ‣ recover ‣ scp ‣ promwish 51 Carlos Becker - Gophercon Latam 2025

Slide 52

Slide 52 text

What's next? 52 Carlos Becker - Gophercon Latam 2025

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

55 Carlos Becker - Gophercon Latam 2025

Slide 56

Slide 56 text

Links ‣ charm.sh ‣ caarlos0.dev ‣ goreleaser.com ‣ github.com/caarlos0/gophercon-2025 56 Carlos Becker - Gophercon Latam 2025