From e69d3e4a8ae0d970cc0ba116e31f60eb913fc58c Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Mon, 13 Apr 2026 20:28:10 -0700 Subject: [PATCH] init: project init --- .opencode/skills/caveman/SKILL.md | 69 +++++++ cmd/tap/main.go | 11 ++ examples/echo/main.go | 114 +++++++++++ flake.lock | 61 ++++++ flake.nix | 41 ++++ go.mod | 5 + go.sum | 2 + internal/app/process.go | 62 ++++++ internal/app/proxy.go | 33 ++++ internal/app/session.go | 52 +++++ internal/cli/run.go | 52 +++++ internal/model/command.go | 6 + internal/model/message.go | 36 ++++ internal/model/proxy.go | 12 ++ internal/process/runner.go | 72 +++++++ internal/proxy/handler.go | 142 ++++++++++++++ internal/proxy/server.go | 38 ++++ internal/tui/model.go | 2 + internal/tui/update.go | 2 + internal/tui/view.go | 2 + proto/main.go | 224 ++++++++++++++++++++++ proto/proxy.go | 303 ++++++++++++++++++++++++++++++ proto/server.go | 42 +++++ 23 files changed, 1383 insertions(+) create mode 100644 .opencode/skills/caveman/SKILL.md create mode 100644 cmd/tap/main.go create mode 100644 examples/echo/main.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/process.go create mode 100644 internal/app/proxy.go create mode 100644 internal/app/session.go create mode 100644 internal/cli/run.go create mode 100644 internal/model/command.go create mode 100644 internal/model/message.go create mode 100644 internal/model/proxy.go create mode 100644 internal/process/runner.go create mode 100644 internal/proxy/handler.go create mode 100644 internal/proxy/server.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/update.go create mode 100644 internal/tui/view.go create mode 100644 proto/main.go create mode 100644 proto/proxy.go create mode 100644 proto/server.go diff --git a/.opencode/skills/caveman/SKILL.md b/.opencode/skills/caveman/SKILL.md new file mode 100644 index 0000000..cada70b --- /dev/null +++ b/.opencode/skills/caveman/SKILL.md @@ -0,0 +1,69 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman + while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, + wenyan-lite, wenyan-full, wenyan-ultra. + Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", + "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. +license: Proprietary +compatibility: opencode +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode". + +Default: **full**. Switch: `/caveman lite|full|ultra`. + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +## Intensity + +| Level | What change | +|-------|------------| +| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight | +| **full** | Drop articles, fragments OK, short synonyms. Classic caveman | +| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough | +| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register | +| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) | +| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse | + +Example — "Why React component re-render?" +- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`." +- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." +- ultra: "Inline obj prop → new ref → re-render. `useMemo`." +- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。" +- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。" +- wenyan-ultra: "新參照→重繪。useMemo Wrap。" + +Example — "Explain database connection pooling." +- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead." +- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead." +- ultra: "Pool = reuse DB conn. Skip handshake → fast under load." +- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。" +- wenyan-ultra: "池reuse conn。skip handshake → fast。" + +## Auto-Clarity + +Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done. + +Example — destructive op: +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> ```sql +> DROP TABLE users; +> ``` +> Caveman resume. Verify backup exist first. + +## Boundaries + +Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end. diff --git a/cmd/tap/main.go b/cmd/tap/main.go new file mode 100644 index 0000000..1d2257d --- /dev/null +++ b/cmd/tap/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "termtap.dev/internal/cli" +) + +func main() { + cli.Run(os.Args) +} diff --git a/examples/echo/main.go b/examples/echo/main.go new file mode 100644 index 0000000..3f1b675 --- /dev/null +++ b/examples/echo/main.go @@ -0,0 +1,114 @@ +package main + +// This is a runable example which will spawn two servers, one we can access +// which hits the other and response with the data provided. + +import ( + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "sync" +) + +func main() { + upstreamHost, err := findNonLoopbackIPv4() + if err != nil { + log.Fatal(err) + } + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + if err := startUpstream(upstreamHost); err != nil { + log.Fatal(err) + } + }() + + go func() { + defer wg.Done() + if err := startFrontend(upstreamHost); err != nil { + log.Fatal(err) + } + }() + + wg.Wait() +} + +func startFrontend(upstreamHost string) error { + mux := http.NewServeMux() + mux.HandleFunc("/echo", func(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + message := req.URL.Query().Get("message") + upstreamURL := fmt.Sprintf("http://%s:3001/echo?message=%s", upstreamHost, url.QueryEscape(message)) + + resp, err := http.Get(upstreamURL) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(body) + }) + + log.Printf("frontend listening on http://127.0.0.1:3000/echo?message=hello") + log.Printf("frontend calls upstream at http://%s:3001/echo", upstreamHost) + return http.ListenAndServe("127.0.0.1:3000", mux) +} + +func startUpstream(upstreamHost string) error { + mux := http.NewServeMux() + mux.HandleFunc("/echo", func(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + message := req.URL.Query().Get("message") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(message)) + }) + + log.Printf("upstream listening on http://%s:3001/echo?message=hello", upstreamHost) + return http.ListenAndServe(":3001", mux) +} + +func findNonLoopbackIPv4() (string, error) { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "", err + } + + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok || ipNet.IP == nil { + continue + } + + ip := ipNet.IP.To4() + if ip == nil || ip.IsLoopback() { + continue + } + + return ip.String(), nil + } + + return "", fmt.Errorf("no non-loopback IPv4 address found; this demo needs one so outbound traffic does not bypass the proxy") +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..57640e9 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1775710090, + "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4c1018dae018162ec878d42fec712642d214fdfa", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e1b361b --- /dev/null +++ b/flake.nix @@ -0,0 +1,41 @@ +{ + description = "TermTap Development Flake"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + go + gopls + go-tools + gcc_multi + glibc_multi + ]; + + name = "TermTap"; + inherit (pkgs) zsh; + + shellHook = '' + # Use the .local directory instead of home + export GOPATH="$HOME/.local/go" + echo "Settings GOPATH to: $HOME/.local/go " + + export GOOS=linux + export GOARCH=amd64 + export CGO_CFLAGS=-Wno-error=cpp; + + exec zsh + ''; + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ce63657 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module termtap.dev + +go 1.26.1 + +require github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/app/process.go b/internal/app/process.go new file mode 100644 index 0000000..bdaae32 --- /dev/null +++ b/internal/app/process.go @@ -0,0 +1,62 @@ +package app + +import ( + "errors" + "fmt" + "os" + "os/exec" + + "termtap.dev/internal/model" + "termtap.dev/internal/process" +) + +func StartProcess(cmd model.Command, addr string, ch chan<- model.Message, sigCh <-chan os.Signal) { + ch <- model.Message{ + Type: model.MessageTypeProcessStarting, + Body: fmt.Sprintf("spawning process '%s'", process.CommandString(cmd)), + } + + proc := process.NewProcess(cmd, addr, ch) + + if err := proc.Start(); err != nil { + ch <- model.Message{ + Type: model.MessageTypeProcessExited, + Body: fmt.Sprintf("%q", err), + } + return + } + + // Listen for SIGTERM from main process + go func() { + sig := <-sigCh + + ch <- model.Message{ + Type: model.MessageTypeProcessSignaled, + Body: fmt.Sprintf("process with pid '%d' is being killed", proc.Process.Pid), + PID: proc.Process.Pid, + } + + if proc.Process != nil { + _ = proc.Process.Signal(sig) + } + }() + + if err := proc.Wait(); err != nil { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + ch <- model.Message{ + Type: model.MessageTypeProcessExited, + Body: "process killed itself", + PID: proc.Process.Pid, + ExitCode: exitErr.ExitCode(), + } + return + } + + ch <- model.Message{ + Type: model.MessageTypeFatal, + Body: fmt.Sprintf("%q", err), + } + return + } + +} diff --git a/internal/app/proxy.go b/internal/app/proxy.go new file mode 100644 index 0000000..b1d6856 --- /dev/null +++ b/internal/app/proxy.go @@ -0,0 +1,33 @@ +package app + +import ( + "fmt" + + "termtap.dev/internal/model" + "termtap.dev/internal/proxy" +) + +func StartProxy(addr string, ch chan<- model.Message) { + ps, err := proxy.NewProxyServer(addr, ch) + if err != nil { + ch <- model.Message{ + Type: model.MessageTypeFatal, + Body: fmt.Sprintf("%q", err), + } + return + } + defer proxy.Destory(ps) + + ch <- model.Message{ + Type: model.MessageTypeProxyStarting, + Body: fmt.Sprintf("proxy server started on %s", addr), + } + + if err := ps.Server.Serve(*ps.Listener); err != nil { + ch <- model.Message{ + Type: model.MessageTypeFatal, + Body: fmt.Sprintf("%q", err), + } + return + } +} diff --git a/internal/app/session.go b/internal/app/session.go new file mode 100644 index 0000000..f491af4 --- /dev/null +++ b/internal/app/session.go @@ -0,0 +1,52 @@ +package app + +import ( + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "termtap.dev/internal/model" +) + +func StartSession(cmd model.Command, addr string) error { + + // Event type? + msgs := make(chan model.Message, 128) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigCh) + + // Start process and proxy + go StartProxy(addr, msgs) + go StartProcess(cmd, addr, msgs, sigCh) + + var events []model.Message + + for { + select { + case _ = <-sigCh: + printEvents(events) + return nil + case msg := <-msgs: + { + events = append(events, msg) + switch msg.Type { + case model.MessageTypeFatal: + return fmt.Errorf("%s", msg.Body) + default: + log.Printf("[%s] %s", msg.Type, msg.Body) + } + } + } + } +} + +// DEBUG +func printEvents(events []model.Message) { + for _, event := range events { + fmt.Printf("%+v\n", event) + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 0000000..9804415 --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,52 @@ +package cli + +import ( + "fmt" + "log" + "os" + + "termtap.dev/internal/app" + "termtap.dev/internal/model" +) + +// This should be configurable at some point, just in case they build on 8080 +const proxy_addr = "127.0.0.1:8080" + +func Run(args []string) { + cmd, ok := parseCommand(args) + if !ok { + displayHelp() + return + } + + err := app.StartSession(cmd, proxy_addr) + if err != nil { + log.Fatalln(err) + } +} + +func parseCommand(args []string) (model.Command, bool) { + if len(args) < 4 { + return model.Command{}, false + } + + if args[1] != "run" || args[2] != "--" { + return model.Command{}, false + } + + args = args[3:] + if len(args) == 1 { + return model.Command{Name: args[0], Args: []string{}}, true + } + + return model.Command{Name: args[0], Args: args[1:]}, true +} + +func displayHelp() { + helpText := ` +usage: + tap run -- [args...] +` + + fmt.Fprintln(os.Stderr, helpText) +} diff --git a/internal/model/command.go b/internal/model/command.go new file mode 100644 index 0000000..91becd5 --- /dev/null +++ b/internal/model/command.go @@ -0,0 +1,6 @@ +package model + +type Command struct { + Name string + Args []string +} diff --git a/internal/model/message.go b/internal/model/message.go new file mode 100644 index 0000000..3f5f957 --- /dev/null +++ b/internal/model/message.go @@ -0,0 +1,36 @@ +package model + +type MessageType string + +const ( + MessageTypeSessionStarted MessageType = "SessionStarted" + MessageTypeSessionStopped MessageType = "SessionStopped" + + MessageTypeProxyStarting MessageType = "ProxyStarting" + MessageTypeProxyStarted MessageType = "ProxyStarted" + MessageTypeProxyStopped MessageType = "ProxyStopped" + + MessageTypeRequestStarted MessageType = "RequestStarted" + MessageTypeRequestFinished MessageType = "RequestFinished" + MessageTypeRequestFailed MessageType = "RequestFailed" + + MessageTypeProcessStarting MessageType = "ProcessStarting" + MessageTypeProcessStarted MessageType = "ProcessStarted" + MessageTypeProcessExited MessageType = "ProcessExited" + MessageTypeProcessSignaled MessageType = "ProcessSignaled" + MessageTypeProcessStdout MessageType = "ProcessStdout" + MessageTypeProcessStderr MessageType = "ProcessStderr" + + MessageTypeFatal MessageType = "Fatal" + MessageTypeWarn MessageType = "Warn" +) + +type Message struct { + Type MessageType + Body string + PID int + RequestID string + URL string + Status int + ExitCode int +} diff --git a/internal/model/proxy.go b/internal/model/proxy.go new file mode 100644 index 0000000..14b653f --- /dev/null +++ b/internal/model/proxy.go @@ -0,0 +1,12 @@ +package model + +import ( + "net" + "net/http" +) + +type ProxyServer struct { + Listener *net.Listener + Server *http.Server + Url string +} diff --git a/internal/process/runner.go b/internal/process/runner.go new file mode 100644 index 0000000..491ece4 --- /dev/null +++ b/internal/process/runner.go @@ -0,0 +1,72 @@ +package process + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "termtap.dev/internal/model" +) + +func CommandString(c model.Command) string { + return fmt.Sprintf("%s %s", c.Name, strings.Join(c.Args, " ")) +} + +func NewProcess(cmd model.Command, addr string, ch chan<- model.Message) *exec.Cmd { + proc := exec.Command(cmd.Name, cmd.Args...) + + injectEnv(proc, addr) + + stdout, err := proc.StdoutPipe() + if err != nil { + ch <- model.Message{ + Type: model.MessageTypeWarn, + Body: fmt.Sprintf("could not open stdout pipe: %q", err), + PID: proc.Process.Pid, + } + } else { + go readPipe(stdout, model.MessageTypeProcessStdout, ch) + } + + stderr, err := proc.StderrPipe() + if err != nil { + ch <- model.Message{ + Type: model.MessageTypeWarn, + Body: fmt.Sprintf("could not open stderr pipe: %q", err), + PID: proc.Process.Pid, + } + } else { + go readPipe(stderr, model.MessageTypeProcessStderr, ch) + } + + return proc +} + +func injectEnv(proc *exec.Cmd, addr string) { + proxyAddr := "http://" + addr + injected := []string{ + "HTTP_PROXY=" + proxyAddr, + "http_proxy=" + proxyAddr, + "HTTPS_PROXY=" + proxyAddr, // TODO: HTTP NOT SUPPORTED + "https_proxy=" + proxyAddr, + // "ALL_PROXY=" + proxyAddr, + // "all_proxy=" + proxyAddr, + "NO_PROXY=", + "no_proxy=", + } + + proc.Env = append(os.Environ(), injected...) +} + +func readPipe(pipe io.Reader, t model.MessageType, ch chan<- model.Message) { + scanner := bufio.NewScanner(pipe) + for scanner.Scan() { + ch <- model.Message{ + Type: t, + Body: scanner.Text(), + } + } +} diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go new file mode 100644 index 0000000..bc98600 --- /dev/null +++ b/internal/proxy/handler.go @@ -0,0 +1,142 @@ +package proxy + +import ( + "bytes" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "termtap.dev/internal/model" +) + +// NOTE: Much of this code is AI generated, and is not expected to make it into production + +const maxPreviewBytes = 1024 + +func proxyHandler(ch chan<- model.Message) http.Handler { + transport := http.DefaultTransport + + // TODO: This should be wired into the main channel, but that will require a model package + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method == http.MethodConnect { + http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented) + ch <- model.Message{ + Type: model.MessageTypeWarn, + Body: fmt.Sprintf("CONNECT is not supported: %s", req.Host), + } + return + } + + if req.URL.Scheme == "" || req.URL.Host == "" { + http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest) + ch <- model.Message{ + Type: model.MessageTypeWarn, + Body: fmt.Sprintf("rejected non-proxy request %s %s", req.Method, req.URL.String()), + } + return + } + + start := time.Now() + // requestPreview, err := readAndRestoreBody(&req.Body) + // if err != nil { + // http.Error(w, "failed to read request body", http.StatusBadRequest) + // log.Printf("!! read request body %s %s: %v", req.Method, req.URL.String(), err) + // return + // } + + outReq := req.Clone(req.Context()) + outReq.RequestURI = "" + ch <- model.Message{ + Type: model.MessageTypeRequestStarted, + Body: fmt.Sprintf("-> %s %s", outReq.Method, outReq.URL.String()), + } + + resp, err := transport.RoundTrip(outReq) + if err != nil { + http.Error(w, "bad gateway", http.StatusBadGateway) + ch <- model.Message{ + Type: model.MessageTypeRequestFailed, + Body: fmt.Sprintf("upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err), + } + return + } + defer resp.Body.Close() + + // responsePreview, err := readAndRestoreBody(&resp.Body) + // if err != nil { + // http.Error(w, "bad gateway", http.StatusBadGateway) + // log.Printf("!! read response body %s %s: %v", outReq.Method, outReq.URL.String(), err) + // return + // } + + copyHeader(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + if _, err := io.Copy(w, resp.Body); err != nil { + ch <- model.Message{ + Type: model.MessageTypeRequestFailed, + Body: fmt.Sprintf("write response body %s %s: %v", outReq.Method, outReq.URL.String(), err), + } + return + } + + ch <- model.Message{ + Type: model.MessageTypeRequestFinished, + Body: fmt.Sprintf("<- %s %s %d %s", + outReq.Method, + outReq.URL.String(), + resp.StatusCode, + time.Since(start).Round(time.Millisecond), + ), + } + }) +} + +func copyHeader(dst, src http.Header) { + for key, values := range src { + for _, value := range values { + dst.Add(key, value) + } + } +} + +func readAndRestoreBody(body *io.ReadCloser) (string, error) { + if body == nil || *body == nil { + return "", nil + } + + payload, err := io.ReadAll(*body) + if err != nil { + return "", err + } + + *body = io.NopCloser(bytes.NewReader(payload)) + + preview := payload + if len(preview) > maxPreviewBytes { + preview = preview[:maxPreviewBytes] + } + + text := strings.ReplaceAll(string(preview), "\n", "\\n") + if len(payload) > maxPreviewBytes { + text += "..." + } + + return text, nil +} + +func formatHeaders(headers http.Header) string { + if len(headers) == 0 { + return "" + } + + parts := make([]string, 0, len(headers)) + for key, values := range headers { + parts = append(parts, fmt.Sprintf("%s=%q", key, strings.Join(values, ","))) + } + sort.Strings(parts) + + return strings.Join(parts, ", ") +} diff --git a/internal/proxy/server.go b/internal/proxy/server.go new file mode 100644 index 0000000..414c192 --- /dev/null +++ b/internal/proxy/server.go @@ -0,0 +1,38 @@ +package proxy + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "termtap.dev/internal/model" +) + +func NewProxyServer(addr string, ch chan<- model.Message) (*model.ProxyServer, error) { + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, err + } + + url := fmt.Sprintf("http://%s", listener.Addr().String()) + + ps := &model.ProxyServer{ + Listener: &listener, + Server: &http.Server{Handler: proxyHandler(ch)}, + Url: url, + } + + return ps, nil +} + +// BUG: Not sure what all this does +func Destory(ps *model.ProxyServer) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if ps != nil && ps.Server != nil { + _ = ps.Server.Shutdown(ctx) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..c874d24 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,2 @@ +package tui + diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..c874d24 --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,2 @@ +package tui + diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..c874d24 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,2 @@ +package tui + diff --git a/proto/main.go b/proto/main.go new file mode 100644 index 0000000..40a76f3 --- /dev/null +++ b/proto/main.go @@ -0,0 +1,224 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "sort" + "strings" + "time" + + "github.com/google/uuid" +) + +func main() { + if err := parseArgs(); err != nil { + panic(err) + } +} + +func parseArgs() error { + if len(os.Args) < 3 { + return fmt.Errorf("Must use this right") + } + + if os.Args[1] != "run" || os.Args[2] != "--" { + return fmt.Errorf("Must use this right") + } + + cmd := os.Args[3:] + return run(cmd) +} + +func run(cmd []string) error { + fmt.Printf("%+v\n", cmd) + + server, url, err := proxy() + if err != nil { + return err + } + + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + }() + + println(url) + + env := []string{ + "HTTP_PROXY=" + url, + "http_proxy=" + url, + "HTTPS_PROXY=" + url, + "https_proxy=" + url, + "ALL_PROXY=" + url, + "all_proxy=" + url, + "NO_PROXY=", + "no_proxy=", + } + + proc := exec.Command(cmd[0], cmd[1:]...) + proc.Stdin = os.Stdin + proc.Stdout = os.Stdout + proc.Stderr = os.Stderr + proc.Env = append(os.Environ(), env...) + + if err := proc.Start(); err != nil { + return err + } + + if err := proc.Wait(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + return fmt.Errorf("wait for command: %w", err) + } + + return nil +} + +func proxy() (*http.Server, string, error) { + addr := "127.0.0.1:8080" + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, "", err + } + + server := &http.Server{Handler: handler()} + + go func() { + if err := server.Serve(listener); err != nil { + fmt.Printf("%q", err) + } + }() + + url := "http://" + listener.Addr().String() + return server, url, nil +} + +func handler() http.Handler { + transport := http.DefaultTransport + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method == http.MethodConnect { + http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented) + log.Printf("!! CONNECT %s not supported", req.Host) + return + } + + if req.URL.Scheme == "" || req.URL.Host == "" { + http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest) + log.Printf("!! rejected non-proxy request %s %s", req.Method, req.URL.String()) + return + } + + startedAt := time.Now() + id := uuid.New().String() + // requestPreview, err := readAndRestoreBody(&req.Body) + // if err != nil { + // http.Error(w, "failed to read request body", http.StatusBadRequest) + // log.Printf("!! read request body %s %s: %v", req.Method, req.URL.String(), err) + // return + // } + + outReq := req.Clone(req.Context()) + outReq.RequestURI = "" + + log.Printf( + "[%s] -> %s %s\n", + id, + outReq.Method, + outReq.URL.String(), + // formatHeaders(outReq.Header), + // requestPreview, + ) + + resp, err := transport.RoundTrip(outReq) + if err != nil { + http.Error(w, "bad gateway", http.StatusBadGateway) + log.Printf("!! upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err) + return + } + defer resp.Body.Close() + + // responsePreview, err := readAndRestoreBody(&resp.Body) + // if err != nil { + // http.Error(w, "bad gateway", http.StatusBadGateway) + // log.Printf("!! read response body %s %s: %v", outReq.Method, outReq.URL.String(), err) + // return + // } + + copyHeader(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + if _, err := io.Copy(w, resp.Body); err != nil { + log.Printf("!! write response body %s %s: %v", outReq.Method, outReq.URL.String(), err) + return + } + + log.Printf( + "[%s] <- %s %s %d %s\n", + id, + outReq.Method, + outReq.URL.String(), + resp.StatusCode, + time.Since(startedAt).Round(time.Millisecond), + ) + }) +} + +const maxPreviewBytes = 1024 + +func copyHeader(dst, src http.Header) { + for key, values := range src { + for _, value := range values { + dst.Add(key, value) + } + } +} + +func readAndRestoreBody(body *io.ReadCloser) (string, error) { + if body == nil || *body == nil { + return "", nil + } + + payload, err := io.ReadAll(*body) + if err != nil { + return "", err + } + + *body = io.NopCloser(bytes.NewReader(payload)) + + preview := payload + if len(preview) > maxPreviewBytes { + preview = preview[:maxPreviewBytes] + } + + text := strings.ReplaceAll(string(preview), "\n", "\\n") + if len(payload) > maxPreviewBytes { + text += "..." + } + + return text, nil +} + +func formatHeaders(headers http.Header) string { + if len(headers) == 0 { + return "" + } + + parts := make([]string, 0, len(headers)) + for key, values := range headers { + parts = append(parts, fmt.Sprintf("%s=%q", key, strings.Join(values, ","))) + } + sort.Strings(parts) + + return strings.Join(parts, ", ") +} diff --git a/proto/proxy.go b/proto/proxy.go new file mode 100644 index 0000000..0a60382 --- /dev/null +++ b/proto/proxy.go @@ -0,0 +1,303 @@ +package main + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "sort" + "strings" + "syscall" + "time" +) + +const maxPreviewBytes = 1024 + +func test() { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + switch os.Args[1] { + case "run": + if err := runCommand(os.Args[2:]); err != nil { + log.Fatal(err) + } + case "proxy": + if err := runProxy(os.Args[2:]); err != nil { + log.Fatal(err) + } + default: + printUsage() + os.Exit(1) + } +} + +func printUsage() { + fmt.Fprintln(os.Stderr, "usage:") + fmt.Fprintln(os.Stderr, " tap run -- [args...]") + fmt.Fprintln(os.Stderr, " tap proxy [-listen 127.0.0.1:8080]") +} + +func runCommand(args []string) error { + runFlags := flag.NewFlagSet("run", flag.ExitOnError) + listenAddr := runFlags.String("listen", "127.0.0.1:0", "proxy listen address") + runFlags.SetOutput(io.Discard) + + if err := runFlags.Parse(args); err != nil { + return err + } + + commandArgs := runFlags.Args() + if len(commandArgs) == 0 { + return errors.New("run requires a command after `--`") + } + if commandArgs[0] == "--" { + commandArgs = commandArgs[1:] + } + if len(commandArgs) == 0 { + return errors.New("run requires a command after `--`") + } + + server, proxyURL, err := startProxy(*listenAddr) + if err != nil { + return err + } + defer shutdownServer(server) + + log.Printf("proxy listening on %s", proxyURL) + + cmd := exec.Command(commandArgs[0], commandArgs[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = withProxyEnv(os.Environ(), proxyURL) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start command: %w", err) + } + + forwardSignals(cmd.Process) + + if err := cmd.Wait(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + return fmt.Errorf("wait for command: %w", err) + } + + return nil +} + +func runProxy(args []string) error { + proxyFlags := flag.NewFlagSet("proxy", flag.ExitOnError) + listenAddr := proxyFlags.String("listen", "127.0.0.1:8080", "proxy listen address") + if err := proxyFlags.Parse(args); err != nil { + return err + } + + server, proxyURL, err := startProxy(*listenAddr) + if err != nil { + return err + } + defer shutdownServer(server) + + log.Printf("proxy listening on %s", proxyURL) + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(stop) + <-stop + + return nil +} + +func startProxy(listenAddr string) (*http.Server, string, error) { + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + return nil, "", fmt.Errorf("listen on %s: %w", listenAddr, err) + } + + server := &http.Server{Handler: newForwardProxy()} + + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("proxy server error: %v", err) + } + }() + + proxyURL := "http://" + listener.Addr().String() + return server, proxyURL, nil +} + +func shutdownServer(server *http.Server) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) +} + +func withProxyEnv(env []string, proxyURL string) []string { + filtered := make([]string, 0, len(env)+5) + for _, entry := range env { + if hasEnvKey(entry, "HTTP_PROXY") || hasEnvKey(entry, "http_proxy") || hasEnvKey(entry, "HTTPS_PROXY") || hasEnvKey(entry, "https_proxy") || hasEnvKey(entry, "ALL_PROXY") || hasEnvKey(entry, "all_proxy") || hasEnvKey(entry, "NO_PROXY") || hasEnvKey(entry, "no_proxy") { + continue + } + filtered = append(filtered, entry) + } + + filtered = append(filtered, + "HTTP_PROXY="+proxyURL, + "http_proxy="+proxyURL, + "HTTPS_PROXY="+proxyURL, + "https_proxy="+proxyURL, + "ALL_PROXY="+proxyURL, + "all_proxy="+proxyURL, + "NO_PROXY=", + "no_proxy=", + ) + + return filtered +} + +func hasEnvKey(entry, key string) bool { + return strings.HasPrefix(entry, key+"=") +} + +func forwardSignals(process *os.Process) { + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt, syscall.SIGTERM) + + go func() { + for sig := range ch { + _ = process.Signal(sig) + } + }() +} + +func newForwardProxy() http.Handler { + transport := http.DefaultTransport + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method == http.MethodConnect { + http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented) + log.Printf("!! CONNECT %s not supported", req.Host) + return + } + + if req.URL.Scheme == "" || req.URL.Host == "" { + http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest) + log.Printf("!! rejected non-proxy request %s %s", req.Method, req.URL.String()) + return + } + + startedAt := time.Now() + requestPreview, err := readAndRestoreBody(&req.Body) + if err != nil { + http.Error(w, "failed to read request body", http.StatusBadRequest) + log.Printf("!! read request body %s %s: %v", req.Method, req.URL.String(), err) + return + } + + outReq := req.Clone(req.Context()) + outReq.RequestURI = "" + + log.Printf( + "-> %s %s\n request headers: %s\n request body: %q", + outReq.Method, + outReq.URL.String(), + formatHeaders(outReq.Header), + requestPreview, + ) + + resp, err := transport.RoundTrip(outReq) + if err != nil { + http.Error(w, "bad gateway", http.StatusBadGateway) + log.Printf("!! upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err) + return + } + defer resp.Body.Close() + + responsePreview, err := readAndRestoreBody(&resp.Body) + if err != nil { + http.Error(w, "bad gateway", http.StatusBadGateway) + log.Printf("!! read response body %s %s: %v", outReq.Method, outReq.URL.String(), err) + return + } + + copyHeader(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + if _, err := io.Copy(w, resp.Body); err != nil { + log.Printf("!! write response body %s %s: %v", outReq.Method, outReq.URL.String(), err) + return + } + + log.Printf( + "<- %s %s %d %s\n response headers: %s\n response body: %q", + outReq.Method, + outReq.URL.String(), + resp.StatusCode, + time.Since(startedAt).Round(time.Millisecond), + formatHeaders(resp.Header), + responsePreview, + ) + }) +} + +func copyHeader(dst, src http.Header) { + for key, values := range src { + for _, value := range values { + dst.Add(key, value) + } + } +} + +func readAndRestoreBody(body *io.ReadCloser) (string, error) { + if body == nil || *body == nil { + return "", nil + } + + payload, err := io.ReadAll(*body) + if err != nil { + return "", err + } + + *body = io.NopCloser(bytes.NewReader(payload)) + + preview := payload + if len(preview) > maxPreviewBytes { + preview = preview[:maxPreviewBytes] + } + + text := strings.ReplaceAll(string(preview), "\n", "\\n") + if len(payload) > maxPreviewBytes { + text += "..." + } + + return text, nil +} + +func formatHeaders(headers http.Header) string { + if len(headers) == 0 { + return "" + } + + parts := make([]string, 0, len(headers)) + for key, values := range headers { + parts = append(parts, fmt.Sprintf("%s=%q", key, strings.Join(values, ","))) + } + sort.Strings(parts) + + return strings.Join(parts, ", ") +} diff --git a/proto/server.go b/proto/server.go new file mode 100644 index 0000000..3b044b8 --- /dev/null +++ b/proto/server.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "io" + "net/http" +) + +func main() { + if err := startDemoServer("127.0.0.1:3000"); err != nil { + panic(err) + } +} + +func startDemoServer(addr string) error { + mux := http.NewServeMux() + mux.HandleFunc("/send", func(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + resp, err := http.Get("http://example.com") + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, "sent request to http://example.com\nstatus: %s\nbytes: %d\n", resp.Status, len(body)) + }) + + fmt.Printf("demo server listening on http://%s/send\n", addr) + return http.ListenAndServe(addr, mux) +}