cmd/httptap/main.go

// Command httptap is a small CLI that attaches to a running process and
// renders its HTTP traffic in a terminal UI.
//
// See mercemay.top/src/httptap/ for the full write-up.
package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"os/signal"
	"strings"
	"syscall"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/spf13/cobra"

	"mercemay.top/src/httptap/internal/tracer"
	"mercemay.top/src/httptap/internal/tui"
)

type options struct {
	pids        []int
	procName    string
	followForks bool
	goSymbols   string
	extraProbes []string
	noColor     bool
}

func main() {
	var opts options

	root := &cobra.Command{
		Use:           "httptap",
		Short:         "inspect a process's HTTP traffic from a terminal",
		SilenceUsage:  true,
		SilenceErrors: true,
		RunE: func(cmd *cobra.Command, _ []string) error {
			return run(cmd.Context(), opts)
		},
	}

	f := root.Flags()
	f.IntSliceVarP(&opts.pids, "pid", "p", nil, "attach to pid (repeatable)")
	f.StringVarP(&opts.procName, "name", "n", "", "attach to the first process whose comm matches")
	f.BoolVar(&opts.followForks, "follow-forks", false, "also attach to child processes")
	f.StringVar(&opts.goSymbols, "go-symbols", "", "path to a Go binary to resolve crypto/tls symbols from")
	f.StringSliceVar(&opts.extraProbes, "probe", nil, "extra probe spec, name:library (repeatable)")
	f.BoolVar(&opts.noColor, "no-color", false, "disable coloured output")

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	if err := root.ExecuteContext(ctx); err != nil {
		fmt.Fprintln(os.Stderr, "httptap:", err)
		os.Exit(1)
	}
}

func run(ctx context.Context, opts options) error {
	if len(opts.pids) == 0 && opts.procName == "" {
		return errors.New("need --pid or --name")
	}

	if opts.procName != "" {
		pid, err := tracer.FindPIDByName(opts.procName)
		if err != nil {
			return fmt.Errorf("resolve %q: %w", opts.procName, err)
		}
		opts.pids = append(opts.pids, pid)
	}

	cfg := tracer.Config{
		PIDs:        opts.pids,
		FollowForks: opts.followForks,
		GoSymbols:   opts.goSymbols,
		ExtraProbes: parseProbes(opts.extraProbes),
	}

	tr, err := tracer.Open(cfg)
	if err != nil {
		return fmt.Errorf("open tracer: %w", err)
	}
	defer tr.Close()

	events, errs := tr.Events(ctx)

	model := tui.NewModel(events, errs, tui.Options{NoColor: opts.noColor})
	program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())

	// Stop the TUI if the tracer dies.
	go func() {
		<-ctx.Done()
		program.Quit()
	}()

	_, err = program.Run()
	if err != nil && !errors.Is(err, context.Canceled) {
		return err
	}
	return nil
}

func parseProbes(raw []string) []tracer.ProbeSpec {
	out := make([]tracer.ProbeSpec, 0, len(raw))
	for _, s := range raw {
		name, lib, ok := strings.Cut(s, ":")
		if !ok || name == "" || lib == "" {
			continue
		}
		out = append(out, tracer.ProbeSpec{Symbol: name, Library: lib})
	}
	return out
}