// 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
}