Integrating trzsz with a Custom Go SSH Client

If you are building a custom SSH client, terminal emulator, or internal tooling utilizing golang.org/x/crypto/ssh, integrating trzsz-go directly into your binary is a massive workflow improvement over requiring users to install the external trzsz CLI wrapper.

This guide breaks down the architectural requirements and provides a robust implementation example.

The Conceptual Architecture

In a standard Go SSH implementation, you pipe local Stdin/Stdout directly to the SSH session's pipes.

To enable trzsz, you must intercept this stream by injecting trzsz.NewTrzszFilter between the user and the server.

  os.Stdin  ┌────────┐   os.Stdin   ┌─────────────┐   ServerIn   ┌────────┐
───────────►│        ├─────────────►│             ├─────────────►│        │
            │        │              │ TrzszFilter │              │        │
◄───────────│ Client │◄─────────────┤             │◄─────────────┤ Server │
  os.Stdout │        │   os.Stdout  └─────────────┘   ServerOut  │        │
◄───────────│        │◄──────────────────────────────────────────┤        │
  os.Stderr └────────┘                  stderr                   └────────┘

The TrzszFilter runs background goroutines scanning ServerOut for trzsz trigger strings. When detected, it pauses standard data forwarding, handles the file transfer protocol locally, and then resumes normal shell operation.

Implementation Steps

The following code demonstrates a complete SSH shell initialization sequence with trzsz properly attached. (Note: Error handling is truncated for brevity, see examples/ssh_client.go in the repository for the fully compilable source).

1. Terminal Preparation

Before attaching the filter, you must place the terminal into raw mode and request a Pseudo-Terminal (PTY) from the SSH server.

import (
    "os"
    "golang.org/x/term"
    "golang.org/x/crypto/ssh"
)

func startSession(client *ssh.Client, session *ssh.Session) error {
    fd := int(os.Stdin.Fd())

    // Put the terminal into raw mode
    state, err := term.MakeRaw(fd)
    if err != nil { return err }
    defer term.Restore(fd, state)

    // Get terminal dimensions
    width, height, _ := term.GetSize(fd)

    // Request the PTY
    session.RequestPty("xterm-256color", height, width, ssh.TerminalModes{})

2. Injecting the Filter

Obtain the IO pipes from the SSH session and instantiate the filter.

    serverIn, _ := session.StdinPipe()
    serverOut, _ := session.StdoutPipe()
    session.Stderr = os.Stderr // Stderr is typically passed through untouched

    // Instantiate the Filter
    trzszFilter := trzsz.NewTrzszFilter(os.Stdin, os.Stdout, serverIn, serverOut,
        trzsz.TrzszOptions{
            TerminalColumns: int32(width), // Required for accurate progress bars
            DetectDragFile:  true,         // Enables drag-and-drop
            EnableZmodem:    true,         // Supports legacy lrzsz
            EnableOSC52:     true,         // Supports clipboard integration
        })

    // CRITICAL: Always clean up the filter routines on exit
    defer trzszFilter.Close()
    defer trzszFilter.ResetTerminal()

3. Handling Window Resizes (SIGWINCH)

If the user resizes their terminal window, both the SSH server and the TrzszFilter need to know about the new dimensions, otherwise, progress bars will render incorrectly.

    ch := make(chan os.Signal, 1)
    // Note: Use signal.Notify(ch, syscall.SIGWINCH) on Unix systems
    go func() {
        for range ch {
            width, height, err := term.GetSize(fd)
            if err == nil {
                session.WindowChange(height, width)
                trzszFilter.SetTerminalColumns(int32(width))
            }
        }
    }()
    defer func() { signal.Stop(ch); close(ch) }()

4. Advanced: High-Speed TCP Tunneling

By default, trzsz transfers data over the standard SSH PTY stdout stream. PTYs are incredibly inefficient for large binary transfers because they parse and interpret characters (like translating \n to \r\n).

To achieve maximum throughput, trzsz supports opening a direct TCP port tunnel over the SSH connection. You must provide a connector callback to the filter so it knows how to dial back to the server when requested.

    // Provide a Dial hook to bypass the PTY for binary transfers
    trzszFilter.SetTunnelConnector(func(port int) net.Conn {
        // client is the *ssh.Client
        conn, _ := client.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
        return conn
    })

5. Start the Shell

Finally, invoke the shell and wait for the session to conclude.

    session.Shell()
    session.Wait()
    return nil
}

Important Consideration: Intercepting Local I/O

In the example above, os.Stdin and os.Stdout are mapped directly to the filter. If your custom application also needs to print messages to the screen (e.g., "Connection Established"), doing so will race with the filter's output rendering.

If you need strict control over local IO, use io.Pipe() to create custom client input/output buffers, pass the pipe ends into NewTrzszFilter, and carefully orchestrate your local console writes.