Compare commits

..

No commits in common. "365fb43ecaf0a810e56320e83d3f0621678ca74e" and "453a26e52961d6afccf6ddf55e8c8a2257f0e53c" have entirely different histories.

20 changed files with 234 additions and 1549 deletions

View File

@ -11,7 +11,6 @@ permission:
"git log*": allow "git log*": allow
"git *": allow "git *": allow
"grep *": allow "grep *": allow
"go *": allow
webfetch: deny webfetch: deny
color: "#e01da6" color: "#e01da6"
--- ---

View File

@ -1,262 +0,0 @@
---
description: You are GoTest-Writer, a Senior Go Engineer specializing in writing idiomatic, performant, and thorough tests for Go applications. You have deep expertise in testing HTTP proxies, concurrent systems, and terminal applications built with bubbletea.
mode: primary
model: openai/gpt-5.3-codex
temperature: 0.2
permission:
edit: allow
bash:
"*": ask
"go test *": allow
"go vet *": allow
"go build *": allow
"grep *": allow
"cat *": allow
"ls *": allow
webfetch: deny
color: "#00d7af"
---
# Role Definition
You are `GoTest-Writer`, a Senior Go Engineer specializing in writing idiomatic, performant tests for Go applications. This project is `termtap` — an HTTP/HTTPS intercepting proxy with a bubbletea TUI interface. You write tests that catch real bugs, run fast, and read clearly.
# Project Context
## Architecture
- **`internal/proxy/`** — Core HTTP/HTTPS proxy logic. `handler.go` implements `http.Handler` via `proxyHandler()`, `handleConnect()` for CONNECT/TLS interception, and helpers like `roundTripCapturedRequest`, `bodyPreview`, `stripHopByHopHeaders`, `redactHeaders`. `server.go` manages lifecycle and connection tracking. `certs.go` handles certificate authority.
- **`internal/model/`** — Pure data types: `Event`, `EventType`, `Request`, `ProxyServer`, `Process`, `Command`. No logic — use as building blocks.
- **`internal/app/`** — Orchestration layer wiring proxy, process, and TUI together.
- **`internal/process/`** — Child process lifecycle via `os/exec`.
- **`internal/tui/`** — Bubbletea `Model`, `Update`, `View`, `panes`. Test by calling `Update`/`View` directly.
- **Module path:** `termtap.dev`
## Key Types
```go
// model.Event — emitted to chan<- model.Event throughout the proxy
type Event struct {
Time time.Time
Type EventType // e.g. EventTypeRequestFinished, EventTypeRequestFailed
Body string
PID int
ExitCode int
Request Request
}
// model.Request — captures a proxied HTTP request/response pair
type Request struct {
ID uuid.UUID
Method, RawURL, Host, URL, QueryString string
QueryMap url.Values
RequestData []byte
ResponseData []byte
RequestHeaders http.Header
ResponseHeaders http.Header
Status int
Duration time.Duration
Pending, Failed bool
StartTime time.Time
}
// model.EventType constants
EventTypeRequestStarted = "RequestStarted"
EventTypeRequestFinished = "RequestFinished"
EventTypeRequestFailed = "RequestFailed"
EventTypeProxyStopped = "ProxyStopped"
EventTypeWarn = "Warn"
// ...and more in internal/model/event.go
```
# Testing Patterns
## 1. Table-Driven Tests (Default)
Always use table-driven tests for functions with multiple input/output cases. Use descriptive sub-test names.
```go
func TestRedactHeaders(t *testing.T) {
tests := []struct {
name string
input http.Header
want http.Header
}{
{
name: "redacts Authorization",
input: http.Header{"Authorization": {"Bearer token123"}},
want: http.Header{"Authorization": {"[REDACTED]"}},
},
{
name: "passes through non-sensitive headers",
input: http.Header{"Content-Type": {"application/json"}},
want: http.Header{"Content-Type": {"application/json"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := redactHeaders(tt.input)
// assert...
})
}
}
```
## 2. `net/http/httptest` for HTTP Testing
Use `httptest.NewServer` for integration-style tests and `httptest.NewRecorder` for handler unit tests. Never spin up a real listener when `httptest` suffices.
```go
func TestProxyHandler_ForwardsRequest(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("pong"))
}))
t.Cleanup(upstream.Close)
ch := make(chan model.Event, 16)
ps := &model.ProxyServer{Conns: make(map[net.Conn]struct{})}
handler := proxyHandler(ch, nil, ps)
req := httptest.NewRequest(http.MethodGet, upstream.URL+"/ping", nil)
req.Host = upstream.Listener.Addr().String()
// proxy-form: URL must be absolute
req.RequestURI = upstream.URL + "/ping"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got status %d, want 200", w.Code)
}
}
```
## 3. Channel Event Assertions
The proxy communicates exclusively via `chan model.Event`. Use this helper pattern to drain and assert:
```go
func drainEvents(t *testing.T, ch <-chan model.Event, n int, timeout time.Duration) []model.Event {
t.Helper()
events := make([]model.Event, 0, n)
deadline := time.After(timeout)
for len(events) < n {
select {
case e := <-ch:
events = append(events, e)
case <-deadline:
t.Errorf("timeout waiting for events: got %d of %d", len(events), n)
return events
}
}
return events
}
func hasEventType(events []model.Event, typ model.EventType) bool {
for _, e := range events {
if e.Type == typ {
return true
}
}
return false
}
```
## 4. Custom `RoundTripper` for Transport Mocking
When testing proxy logic without a live upstream, implement `http.RoundTripper` inline — never mock at the network level:
```go
type mockTransport struct {
fn func(*http.Request) (*http.Response, error)
}
func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return m.fn(req)
}
func respondWith(status int, body string, headers http.Header) *mockTransport {
return &mockTransport{fn: func(_ *http.Request) (*http.Response, error) {
resp := &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(body)),
Header: headers,
}
if resp.Header == nil {
resp.Header = make(http.Header)
}
return resp, nil
}}
}
```
## 5. Bubbletea TUI Tests
Test `Update` and `View` directly — no terminal required. Never assert on exact rendered strings; assert on model state.
```go
func TestTUIUpdate_SomeKeyBinding(t *testing.T) {
m := tui.NewModel(cfg)
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}
next, cmd := m.Update(msg)
_ = next
_ = cmd
// assert on next.(tui.Model).SomeField, not rendered output
}
```
## 6. Parallel Tests
Mark independent tests `t.Parallel()`. Always capture the loop variable before spawning parallel subtests.
```go
for _, tt := range tests {
tt := tt // capture
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// ...
})
}
```
## 7. `t.Cleanup` Over `defer`
Use `t.Cleanup` for teardown — it works correctly across parallel subtests and is composable.
```go
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
```
## 8. No `time.Sleep` in Tests
Use channels, `sync.WaitGroup`, or `context.WithTimeout` to synchronize goroutines. If an async event must settle, drain a channel with a timeout instead of sleeping.
## 9. Goroutine-Safe Failure Reporting
Never call `t.Fatal` or `t.Error` inside a goroutine. Report failures back to the test goroutine via a channel:
```go
errc := make(chan error, 1)
go func() {
if err := doSomething(); err != nil {
errc <- err
return
}
errc <- nil
}()
if err := <-errc; err != nil {
t.Fatal(err)
}
```
## 10. Error Path Coverage
For every exported function that returns an error, test at least one failure case. Use `errors.Is` / `errors.As` for assertions — never match on error strings.
# Output Format
Produce a complete, compilable `_test.go` file. Structure:
1. **Package declaration**`package proxy` (white-box, for unexported helpers) or `package proxy_test` (black-box, for public API). Prefer white-box when testing unexported functions.
2. **Imports** — only used imports; no blank imports except for documented side effects.
3. **Shared helpers and mock types** — at the top of the file, before test functions.
4. **Test functions** — one per logical unit under test, table-driven by default.
5. **`// TODO:` comments** — mark test scenarios that require significant infrastructure (e.g., real TLS handshake, OS trust store, PTY) so the author knows what remains.
Name test files to mirror the file under test: `handler_test.go` tests `handler.go`.
# Hard Rules
- **No external test libraries** — use stdlib `testing` only. No `testify`, no `gomock`.
- **Do not mock `model` package types** — use them directly; they are plain structs.
- **Do not assert on terminal/lipgloss rendered strings** — they are brittle. Assert on model state.
- **Do not write tests that depend on wall-clock timing** — use channels and contexts.
- **Always run tests with `-race`** — note this expectation with a comment in files that test concurrent code.
- **Always cover the error path** — a test suite with no error-path tests is incomplete.

View File

@ -1,42 +0,0 @@
# Event Pressure Notes
This is a quick note on potential event-channel pressure in the current proxy architecture.
## Why this matters
Proxy request handling currently emits events synchronously into a shared channel.
If producers are faster than the consumer (TUI/event loop), the channel can fill and block producers.
When that happens, request handling can stall even if upstream/downstream network paths are healthy.
## Where pressure comes from
- Every request can produce multiple lifecycle events (`started`, `finished`, `failed`, warnings).
- CONNECT + MITM flow can emit both tunnel-level and inner-request events.
- Bursty traffic (many small requests, retries, connection churn) amplifies event rate quickly.
## User-visible symptoms
- Request latency spikes that do not match upstream timings.
- Intermittent pauses during high traffic.
- Shutdown/restart feeling delayed when many events are in flight.
## Current risk profile
- Channel buffer size helps absorb bursts, but only up to a point.
- Backpressure is currently coupled to request path execution, so stalls propagate into proxy behavior.
## Mitigation options
1. Introduce non-blocking event enqueue for low-priority events.
- Keep critical events blocking (fatal/start/stop), but drop or coalesce high-volume request events under load.
2. Add an internal event relay.
- Proxy handlers write to a local buffered queue; a dedicated goroutine forwards to the main channel.
3. Coalesce repetitive events.
- Aggregate similar warnings or per-interval request counters instead of per-request chatter.
4. Add lightweight metrics.
- Track dropped/coalesced events and queue depth so pressure is visible during development.
## Practical near-term suggestion
Start with a small event relay + drop policy for non-critical request events when queue depth is high.
This contains proxy-path stalls without changing the external event model too much.

View File

@ -1,148 +0,0 @@
# HTTPS Support Roadmap
##### Drafted April 18th, 2026
## Why this exists
Most modern services communicate over HTTPS. To make termtap immediately useful, HTTPS visibility should be enabled by default, while still providing safe fallback behavior when local certificate trust is not yet configured.
This roadmap defines staged delivery from tunnel-only metadata to full HTTPS payload visibility.
## Product direction
- Default behavior should attempt HTTPS interception (MITM) so users can inspect request/response data with minimal configuration friction.
- If interception is not possible yet (for example, trust root not installed), termtap should clearly guide the user and provide an explicit fallback mode.
- Non-CONNECT HTTP forwarding should continue working as it does today.
## Current baseline
- HTTP proxying for non-CONNECT requests is implemented.
- HTTPS CONNECT requests are currently rejected (`501`).
- Request/event models already support lifecycle events and request metadata previews for HTTP.
## Stage 1: HTTPS Tunnel Visibility (CONNECT passthrough)
### Goal
Support HTTPS connectivity and show tunnel-level activity in TUI/events without decrypting payloads.
### Scope
1. Add CONNECT handling:
- accept `CONNECT host:port`
- hijack downstream connection
- dial upstream TCP target
- return `200 Connection Established`
- pipe bytes both directions until close/error
2. Emit lifecycle events:
- `RequestStarted` when tunnel opens
- `RequestFinished` on clean close
- `RequestFailed` on setup/tunnel errors
3. Populate request metadata:
- `Method=CONNECT`
- `Host`, `RawURL`, status (usually `200` on success), duration
4. Harden tunnel behavior:
- dial timeout
- deterministic close on both sockets
- clear status mapping for dial/hijack failures
### Acceptance criteria
- `curl -v https://example.com` works through termtap proxy.
- CONNECT requests show in TUI/events with status and timing.
- Existing HTTP behavior remains unchanged.
## Stage 2: Default HTTPS Interception (MITM)
### Goal
Enable decrypted HTTPS inspection by default so users can view headers and payload previews.
### Scope
1. Introduce HTTPS modes (runtime):
- `intercept` (default)
- `tunnel` (fallback/explicit opt-out)
2. Add CA management:
- generate/load local root CA certificate + key
- cache in stable app directory
- surface trust/install status at startup
3. Implement interception pipeline:
- on CONNECT in intercept mode:
- terminate TLS from client using dynamic leaf cert for target host
- establish TLS to upstream
- proxy decrypted HTTP request/response through existing capture path
4. Reuse current HTTP capture logic for HTTPS:
- method/path/headers
- body preview cap
- response status/headers/body preview
- request duration + failures
5. UX for trust onboarding:
- clear startup event/warning when CA not trusted
- OS-specific trust instructions
- command to print/export CA certificate path
### Acceptance criteria
- Trusted client requests over HTTPS show decrypted request/response metadata and previews in TUI.
- Untrusted clients fail with clear guidance (not silent breakage).
- Fallback tunnel mode is available and documented.
## Stage 3: Safety and privacy controls
### Goal
Make interception safe for daily use.
### Scope
- Header redaction defaults (`Authorization`, `Cookie`, `Set-Cookie`, API keys).
- Body preview limits and optional full-body capture toggles.
- Host allowlist/denylist for interception scope.
- Exclusion rules for sensitive domains.
- Clear event labels indicating redacted fields.
### Acceptance criteria
- Sensitive headers are redacted by default.
- Configurable capture policies are applied consistently to HTTP and HTTPS.
## Stage 4: Reliability and operability hardening
### Goal
Ensure proxy remains stable under restart, high load, and shutdown edge cases.
### Scope
- Connection deadlines and idle timeout policy.
- Goroutine/socket leak checks for tunnel + intercept paths.
- Restart behavior with active tunnels.
- Improved error taxonomy and user-facing diagnostics.
- Optional metrics counters (active tunnels, intercepted requests, failures).
### Acceptance criteria
- No resource leaks in stress runs.
- Predictable shutdown/restart with active HTTPS traffic.
- Error messages/events are actionable.
## Implementation order
1. Stage 1 CONNECT passthrough with events.
2. Stage 2 interception plumbing with default `intercept` mode and trust onboarding.
3. Stage 3 redaction/capture controls.
4. Stage 4 reliability hardening and diagnostics.
## Notes
- Interception cannot decrypt HTTPS without a trusted local CA.
- Keeping `tunnel` mode as a fallback reduces operational risk while preserving connectivity.
- Defaulting to `intercept` aligns with product intent (inspect HTTPS by default).

View File

@ -20,20 +20,6 @@ func StartProxy(ps *model.ProxyServer, ch chan<- model.Event) {
Body: fmt.Sprintf("proxy server started on %s", (*ps.Listener).Addr().String()), Body: fmt.Sprintf("proxy server started on %s", (*ps.Listener).Addr().String()),
} }
if ps.CAReady && !ps.CATrusted {
body := fmt.Sprintf("HTTPS interception CA available at %s; trust this certificate to inspect HTTPS traffic", ps.CACertPath)
eventType := model.EventTypeWarn
if ps.CACreated {
body = fmt.Sprintf("generated HTTPS interception CA at %s; trust this certificate to inspect HTTPS traffic", ps.CACertPath)
}
ch <- model.Event{
Time: time.Now().Local(),
Type: eventType,
Body: body,
}
}
if err := ps.Server.Serve(*ps.Listener); err != nil { if err := ps.Server.Serve(*ps.Listener); err != nil {
if errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
return return

View File

@ -4,12 +4,9 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"runtime"
"strconv"
"termtap.dev/internal/app" "termtap.dev/internal/app"
"termtap.dev/internal/model" "termtap.dev/internal/model"
"termtap.dev/internal/proxy"
"termtap.dev/internal/tui" "termtap.dev/internal/tui"
) )
@ -17,11 +14,6 @@ import (
const proxy_addr = "127.0.0.1:8080" const proxy_addr = "127.0.0.1:8080"
func Run(args []string) { func Run(args []string) {
if len(args) >= 2 && args[1] == "cert" {
runCert()
return
}
cmd, ok := parseCommand(args) cmd, ok := parseCommand(args)
if !ok { if !ok {
displayHelp() displayHelp()
@ -63,53 +55,8 @@ func parseCommand(args []string) (model.Command, bool) {
func displayHelp() { func displayHelp() {
helpText := ` helpText := `
usage: usage:
tap cert
tap run -- <command> [args...] tap run -- <command> [args...]
` `
fmt.Fprintln(os.Stderr, helpText) fmt.Fprintln(os.Stderr, helpText)
} }
func runCert() {
ca, err := proxy.EnsureCertificateAuthority()
if err != nil {
log.Fatalln(err)
}
certPath := ca.CertPath()
quotedCertPath := strconv.Quote(certPath)
fmt.Printf("Certificate path: %s\n", certPath)
if ca.WasCreated() {
fmt.Println("Created a new local HTTPS interception CA.")
} else {
fmt.Println("Using existing local HTTPS interception CA.")
}
trusted, err := ca.IsTrustedBySystem()
if err != nil {
fmt.Printf("System trust check failed: %v\n", err)
} else if trusted {
fmt.Println("System trust store: trusted")
} else {
fmt.Println("System trust store: not trusted")
}
if runtime.GOOS != "linux" {
fmt.Println("Install this certificate into your OS or client trust store to inspect HTTPS traffic.")
return
}
fmt.Println()
fmt.Println("Trust instructions (Linux):")
fmt.Println("Debian/Ubuntu:")
fmt.Printf(" sudo cp %s /usr/local/share/ca-certificates/termtap.crt\n", quotedCertPath)
fmt.Println(" sudo update-ca-certificates")
fmt.Println("Fedora/RHEL/CentOS:")
fmt.Printf(" sudo cp %s /etc/pki/ca-trust/source/anchors/termtap.crt\n", quotedCertPath)
fmt.Println(" sudo update-ca-trust")
fmt.Println("Arch:")
fmt.Printf(" sudo trust anchor %s\n", quotedCertPath)
fmt.Println()
fmt.Println("Quick curl test:")
fmt.Printf(" curl --proxy http://%s --cacert %s https://example.com\n", proxy_addr, quotedCertPath)
}

View File

@ -4,7 +4,6 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"sync"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -14,14 +13,6 @@ type ProxyServer struct {
Listener *net.Listener Listener *net.Listener
Server *http.Server Server *http.Server
Url string Url string
CACertPath string
CAReady bool
CACreated bool
CATrusted bool
ConnMu sync.Mutex
Conns map[net.Conn]struct{}
} }
type Request struct { type Request struct {

View File

@ -59,7 +59,7 @@ func injectEnv(proc *exec.Cmd, addr string) {
injected := []string{ injected := []string{
"HTTP_PROXY=" + proxyAddr, "HTTP_PROXY=" + proxyAddr,
"http_proxy=" + proxyAddr, "http_proxy=" + proxyAddr,
"HTTPS_PROXY=" + proxyAddr, "HTTPS_PROXY=" + proxyAddr, // TODO: HTTP NOT SUPPORTED
"https_proxy=" + proxyAddr, "https_proxy=" + proxyAddr,
// "ALL_PROXY=" + proxyAddr, // "ALL_PROXY=" + proxyAddr,
// "all_proxy=" + proxyAddr, // "all_proxy=" + proxyAddr,

View File

@ -1,37 +0,0 @@
package proxy
import (
"bufio"
"io"
"net"
)
type bufferedConn struct {
net.Conn
reader io.Reader
}
func (c *bufferedConn) Read(p []byte) (int, error) {
return c.reader.Read(p)
}
func wrapBufferedConn(conn net.Conn, readWriter *bufio.ReadWriter) net.Conn {
if readWriter == nil {
return conn
}
return &bufferedConn{Conn: conn, reader: readWriter}
}
type previewReadCloser struct {
io.ReadCloser
preview *bodyPreview
}
func (p *previewReadCloser) Read(data []byte) (int, error) {
n, err := p.ReadCloser.Read(data)
if n > 0 {
p.preview.Write(data[:n])
}
return n, err
}

View File

@ -1,335 +0,0 @@
package proxy
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
caDirName = "termtap"
caCertName = "mitm-ca-cert.pem"
caKeyName = "mitm-ca-key.pem"
caValidFor = 10 * 365 * 24 * time.Hour
leafValidFor = 7 * 24 * time.Hour
maxLeafCerts = 256
)
type CertificateAuthority struct {
cert *x509.Certificate
key *ecdsa.PrivateKey
certPath string
keyPath string
wasCreated bool
mu sync.Mutex
leafCert map[string]*tls.Certificate
leafOrder []string
}
func loadOrCreateCertificateAuthority() (*CertificateAuthority, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("resolve user config dir: %w", err)
}
baseDir := filepath.Join(configDir, caDirName)
if err := os.MkdirAll(baseDir, 0o700); err != nil {
return nil, fmt.Errorf("create cert dir: %w", err)
}
ca := &CertificateAuthority{
certPath: filepath.Join(baseDir, caCertName),
keyPath: filepath.Join(baseDir, caKeyName),
leafCert: make(map[string]*tls.Certificate),
}
if _, err := os.Stat(ca.certPath); err == nil {
if _, err := os.Stat(ca.keyPath); err == nil {
if err := ca.load(); err != nil {
return nil, err
}
return ca, nil
}
}
if err := ca.create(); err != nil {
return nil, err
}
ca.wasCreated = true
return ca, nil
}
func (ca *CertificateAuthority) load() error {
certPEM, err := os.ReadFile(ca.certPath)
if err != nil {
return fmt.Errorf("read ca cert: %w", err)
}
keyPEM, err := os.ReadFile(ca.keyPath)
if err != nil {
return fmt.Errorf("read ca key: %w", err)
}
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return fmt.Errorf("decode ca cert pem")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return fmt.Errorf("parse ca cert: %w", err)
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return fmt.Errorf("decode ca key pem")
}
key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
if err != nil {
return fmt.Errorf("parse ca key: %w", err)
}
ca.cert = cert
ca.key = key
return nil
}
func (ca *CertificateAuthority) create() error {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate ca key: %w", err)
}
serial, err := randSerialNumber()
if err != nil {
return err
}
now := time.Now()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "termtap Local MITM CA",
Organization: []string{"termtap"},
},
NotBefore: now.Add(-1 * time.Hour),
NotAfter: now.Add(caValidFor),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return fmt.Errorf("create ca cert: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return fmt.Errorf("marshal ca key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := writeFileAtomically(ca.certPath, certPEM, 0o600); err != nil {
return fmt.Errorf("write ca cert: %w", err)
}
if err := writeFileAtomically(ca.keyPath, keyPEM, 0o600); err != nil {
return fmt.Errorf("write ca key: %w", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return fmt.Errorf("parse created ca cert: %w", err)
}
ca.cert = cert
ca.key = key
return nil
}
func (ca *CertificateAuthority) CertificateForHost(host string) (*tls.Certificate, error) {
host = normalizeCertHost(host)
if host == "" {
return nil, fmt.Errorf("empty host for certificate")
}
ca.mu.Lock()
defer ca.mu.Unlock()
if cert, ok := ca.leafCert[host]; ok {
return cert, nil
}
serial, err := randSerialNumber()
if err != nil {
return nil, err
}
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate leaf key: %w", err)
}
now := time.Now()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: host,
},
NotBefore: now.Add(-1 * time.Hour),
NotAfter: now.Add(leafValidFor),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
if ip := net.ParseIP(host); ip != nil {
tmpl.IPAddresses = []net.IP{ip}
} else {
tmpl.DNSNames = []string{host}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &leafKey.PublicKey, ca.key)
if err != nil {
return nil, fmt.Errorf("create leaf cert: %w", err)
}
tlsCert := &tls.Certificate{
Certificate: [][]byte{der, ca.cert.Raw},
PrivateKey: leafKey,
}
leafParsed, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf("parse leaf cert: %w", err)
}
tlsCert.Leaf = leafParsed
ca.leafCert[host] = tlsCert
ca.leafOrder = append(ca.leafOrder, host)
if len(ca.leafOrder) > maxLeafCerts {
evicted := ca.leafOrder[0]
ca.leafOrder = ca.leafOrder[1:]
delete(ca.leafCert, evicted)
}
return tlsCert, nil
}
func (ca *CertificateAuthority) CertPath() string {
if ca == nil {
return ""
}
return ca.certPath
}
func (ca *CertificateAuthority) WasCreated() bool {
if ca == nil {
return false
}
return ca.wasCreated
}
func (ca *CertificateAuthority) IsTrustedBySystem() (bool, error) {
if ca == nil || ca.cert == nil {
return false, fmt.Errorf("certificate authority is unavailable")
}
roots, err := x509.SystemCertPool()
if err != nil {
return false, fmt.Errorf("load system cert pool: %w", err)
}
if roots == nil {
return false, nil
}
_, err = ca.cert.Verify(x509.VerifyOptions{Roots: roots})
if err == nil {
return true, nil
}
if _, ok := errors.AsType[x509.UnknownAuthorityError](err); ok {
return false, nil
}
return false, err
}
func EnsureCertificateAuthority() (*CertificateAuthority, error) {
return loadOrCreateCertificateAuthority()
}
func randSerialNumber() (*big.Int, error) {
limit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, limit)
if err != nil {
return nil, fmt.Errorf("generate serial number: %w", err)
}
return serial, nil
}
func normalizeCertHost(hostport string) string {
host := strings.TrimSpace(hostport)
if host == "" {
return ""
}
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
return parsedHost
}
return host
}
func writeFileAtomically(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmpFile, err := os.CreateTemp(dir, ".termtap-tmp-*")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpPath)
}
}()
if _, err := tmpFile.Write(data); err != nil {
_ = tmpFile.Close()
return err
}
if err := tmpFile.Chmod(perm); err != nil {
_ = tmpFile.Close()
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
cleanup = false
return nil
}

226
internal/proxy/handler.go Normal file
View File

@ -0,0 +1,226 @@
package proxy
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"sort"
"strings"
"time"
"github.com/google/uuid"
"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.Event) 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.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn,
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.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn,
Body: fmt.Sprintf("rejected non-proxy request %s %s", req.Method, req.URL.String()),
}
return
}
start := time.Now()
request := model.Request{
ID: uuid.New(),
ResponseData: []byte{},
RequestData: []byte{},
URL: "",
Status: -1,
Method: "",
Duration: 0,
Pending: true,
Failed: false,
StartTime: start,
}
requestPreview, err := readAndRestoreBody(&req.Body)
if err != nil {
ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn,
Body: fmt.Sprintf("(%s) failed to read request body: %v", getEndOfUUID(request.ID), err),
Request: request,
}
} else {
request.RequestData = []byte(requestPreview)
}
outReq := req.Clone(req.Context())
outReq.RequestURI = ""
request.URL = outReq.URL.Path
request.QueryString = outReq.URL.RawQuery
request.QueryMap = outReq.URL.Query()
request.Host = outReq.Host
request.Method = outReq.Method
request.RequestHeaders = outReq.Header
request.RawURL = outReq.URL.String()
ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeRequestStarted,
Body: fmt.Sprintf("(%s) %s %s", getEndOfUUID(request.ID), request.Method, request.RawURL),
Request: request,
}
resp, err := transport.RoundTrip(outReq)
if err != nil {
status := statusFromUpstreamError(req, resp, err)
http.Error(w, http.StatusText(status), status)
request.Pending = false
request.Failed = true
request.Duration = time.Since(start).Round(time.Microsecond)
request.Status = status
ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeRequestFailed,
Body: fmt.Sprintf("(%s) upstream error: %v", getEndOfUUID(request.ID), err),
Request: request,
}
return
}
defer resp.Body.Close()
responsePreview, err := readAndRestoreBody(&resp.Body)
if err != nil {
ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn,
Body: fmt.Sprintf("(%s) failed to read response body: %v", getEndOfUUID(request.ID), err),
Request: request,
}
} else {
request.ResponseData = []byte(responsePreview)
}
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
request.Pending = false
request.Failed = true
request.Duration = time.Since(start).Round(time.Microsecond)
request.Status = resp.StatusCode
ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeRequestFailed,
Body: fmt.Sprintf("(%s) failed to write response body: %v", getEndOfUUID(request.ID), err),
}
return
}
request.Duration = time.Since(start).Round(time.Microsecond)
request.Status = resp.StatusCode
request.ResponseHeaders = resp.Header
request.Pending = false
ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeRequestFinished,
Body: fmt.Sprintf("(%s) %s %s %d %dms", getEndOfUUID(request.ID), request.Method, request.RawURL, request.Status, request.Duration.Milliseconds()),
Request: request,
}
})
}
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 "<none>"
}
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, ", ")
}
func getEndOfUUID(id uuid.UUID) string {
return id.String()[24:]
}
// BUG: Not sure if this actually works, seems to favor the 502
func statusFromUpstreamError(req *http.Request, resp *http.Response, err error) int {
if resp != nil {
return resp.StatusCode
}
if errors.Is(req.Context().Err(), context.Canceled) {
return http.StatusBadGateway
}
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusGatewayTimeout
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return http.StatusGatewayTimeout
}
return http.StatusBadGateway
}

View File

@ -1,183 +0,0 @@
package proxy
import (
"bufio"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"termtap.dev/internal/model"
)
const connectIdleTimeout = 30 * time.Second
func proxyHandler(ch chan<- model.Event, ca *CertificateAuthority, ps *model.ProxyServer) http.Handler {
transport := newUpstreamTransport()
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodConnect {
handleConnect(w, req, ch, transport, ca, ps)
return
}
if req.URL.Scheme == "" || req.URL.Host == "" {
http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest)
ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn,
Body: fmt.Sprintf("rejected non-proxy request %s %s", req.Method, req.URL.String()),
}
return
}
resp, request, responsePreview, err := roundTripCapturedRequest(req, transport, ch, "", false)
if err != nil {
status := statusFromUpstreamError(req, resp, err)
http.Error(w, http.StatusText(status), status)
failRequest(ch, request, status, fmt.Sprintf("upstream error: %v", err))
return
}
defer resp.Body.Close()
copyHeaders(resp.Header, w.Header())
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
request.ResponseData = responsePreview.Preview()
failRequest(ch, request, resp.StatusCode, fmt.Sprintf("failed to write response body: %v", err))
return
}
request.ResponseData = responsePreview.Preview()
finishRequest(ch, request, resp.StatusCode)
})
}
func handleConnect(w http.ResponseWriter, req *http.Request, ch chan<- model.Event, transport http.RoundTripper, ca *CertificateAuthority, ps *model.ProxyServer) {
start := time.Now()
request := newConnectRequest(req, start)
startRequest(ch, request)
target := req.Host
if !strings.Contains(target, ":") {
target = net.JoinHostPort(target, "443")
}
if ca == nil {
http.Error(w, "HTTPS interception unavailable", http.StatusBadGateway)
failRequest(ch, request, http.StatusBadGateway, "HTTPS interception certificate authority is unavailable")
return
}
leafCert, err := ca.CertificateForHost(target)
if err != nil {
http.Error(w, "failed to prepare interception certificate", http.StatusBadGateway)
failRequest(ch, request, http.StatusBadGateway, fmt.Sprintf("failed to mint interception certificate for %s: %v", target, err))
return
}
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "proxy does not support hijacking", http.StatusInternalServerError)
failRequest(ch, request, http.StatusInternalServerError, "CONNECT hijack is unavailable")
return
}
clientConn, readWriter, err := hijacker.Hijack()
if err != nil {
http.Error(w, "failed to hijack connection", http.StatusInternalServerError)
failRequest(ch, request, http.StatusInternalServerError, fmt.Sprintf("CONNECT hijack failed: %v", err))
return
}
trackConnection(ps, clientConn)
defer func() {
untrackConnection(ps, clientConn)
_ = clientConn.Close()
}()
if err := writeConnectEstablished(clientConn, readWriter); err != nil {
failRequest(ch, request, http.StatusBadGateway, fmt.Sprintf("CONNECT setup failed: %v", err))
return
}
mitmConn := wrapBufferedConn(clientConn, readWriter)
tlsConn := tls.Server(mitmConn, &tls.Config{
Certificates: []tls.Certificate{*leafCert},
MinVersion: tls.VersionTLS12,
})
defer tlsConn.Close()
_ = clientConn.SetDeadline(time.Now().Add(connectIdleTimeout))
if err := tlsConn.Handshake(); err != nil {
failRequest(ch, request, http.StatusBadGateway, fmt.Sprintf("TLS handshake with client failed: %v", err))
return
}
_ = clientConn.SetDeadline(time.Time{})
reader := bufio.NewReader(tlsConn)
writer := bufio.NewWriter(tlsConn)
for {
_ = clientConn.SetReadDeadline(time.Now().Add(connectIdleTimeout))
innerReq, err := http.ReadRequest(reader)
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
finishRequest(ch, request, http.StatusOK)
return
}
failRequest(ch, request, http.StatusBadGateway, fmt.Sprintf("failed to read decrypted HTTPS request: %v", err))
return
}
_ = clientConn.SetReadDeadline(time.Time{})
resp, captured, responsePreview, err := roundTripCapturedRequest(innerReq, transport, ch, target, true)
if err != nil {
discardAndCloseBody(innerReq.Body)
status := statusFromUpstreamError(innerReq, resp, err)
_ = clientConn.SetWriteDeadline(time.Now().Add(connectIdleTimeout))
if writeErr := writePlainHTTPError(writer, status); writeErr != nil {
failRequest(ch, captured, status, fmt.Sprintf("upstream error: %v", err))
failRequest(ch, request, http.StatusBadGateway, fmt.Sprintf("failed to write HTTPS error response: %v", writeErr))
return
}
_ = clientConn.SetWriteDeadline(time.Time{})
failRequest(ch, captured, status, fmt.Sprintf("upstream error: %v", err))
failRequest(ch, request, status, fmt.Sprintf("closing CONNECT tunnel after upstream error: %v", err))
return
}
_ = clientConn.SetWriteDeadline(time.Now().Add(connectIdleTimeout))
if err := resp.Write(writer); err != nil {
resp.Body.Close()
captured.ResponseData = responsePreview.Preview()
failRequest(ch, captured, resp.StatusCode, fmt.Sprintf("failed to write HTTPS response: %v", err))
failRequest(ch, request, http.StatusBadGateway, fmt.Sprintf("failed to write HTTPS response: %v", err))
return
}
if err := writer.Flush(); err != nil {
_ = clientConn.SetWriteDeadline(time.Time{})
resp.Body.Close()
captured.ResponseData = responsePreview.Preview()
failRequest(ch, captured, resp.StatusCode, fmt.Sprintf("failed to flush HTTPS response: %v", err))
failRequest(ch, request, http.StatusBadGateway, fmt.Sprintf("failed to flush HTTPS response: %v", err))
return
}
_ = clientConn.SetWriteDeadline(time.Time{})
captured.ResponseData = responsePreview.Preview()
finishRequest(ch, captured, resp.StatusCode)
shouldClose := innerReq.Close || resp.Close
resp.Body.Close()
if shouldClose {
finishRequest(ch, request, http.StatusOK)
return
}
}
}

View File

@ -1,67 +0,0 @@
package proxy
import (
"net/http"
"strings"
)
var sensitiveHeaders = map[string]struct{}{
"Authorization": {},
"Cookie": {},
"Proxy-Authorization": {},
"Set-Cookie": {},
"X-Api-Key": {},
}
var hopByHopHeaders = []string{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Proxy-Connection",
"Te",
"Trailer",
"Transfer-Encoding",
"Upgrade",
}
// Remove headers that are only required for client<->proxy and proxy<->server communication.
// Otherwise known as hop-by-hop headers. We do not want to show these to users since they are
// used only for internal functioning for the proxy server.
func stripHopByHopHeaders(headers http.Header) {
if headers == nil {
return
}
connectionValues := append([]string(nil), headers.Values("Connection")...)
for _, key := range hopByHopHeaders {
headers.Del(key)
}
for _, value := range connectionValues {
for key := range strings.SplitSeq(value, ",") {
headers.Del(strings.TrimSpace(key))
}
}
}
// Return a new set of headers that has sensitive headers redacted.
//
// TODO: Maybe use '***' length of header?
func redactHeaders(headers http.Header) http.Header {
clone := headers.Clone()
for key := range clone {
if _, ok := sensitiveHeaders[http.CanonicalHeaderKey(key)]; ok {
clone.Set(key, "[REDACTED]")
}
}
return clone
}
func copyHeaders(src, dest http.Header) {
for key, values := range src {
for _, value := range values {
dest.Add(key, value)
}
}
}

View File

@ -1,50 +0,0 @@
package proxy
import (
"bytes"
"strings"
)
const maxPreviewBytes = 1024 * 64 // 64 kb (maybe we want 256kb)
type bodyPreview struct {
enabled bool
truncated bool
buf bytes.Buffer
}
func newBodyPreview(contentType string) *bodyPreview {
return &bodyPreview{enabled: canDisplayContent(contentType)}
}
func (p *bodyPreview) Write(data []byte) {
if p == nil || !p.enabled || len(data) == 0 {
return
}
remaining := maxPreviewBytes - p.buf.Len()
if remaining <= 0 {
p.truncated = true
return
}
if len(data) > remaining {
data = data[:remaining]
p.truncated = true
}
_, _ = p.buf.Write(data)
}
func (p *bodyPreview) Preview() []byte {
if p == nil || !p.enabled || p.buf.Len() == 0 {
return []byte{}
}
text := strings.ReplaceAll(p.buf.String(), "\n", "\\n")
if p.truncated {
text += "..."
}
return []byte(text)
}

View File

@ -1,128 +0,0 @@
package proxy
import (
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"termtap.dev/internal/model"
)
func roundTripCapturedRequest(req *http.Request, transport http.RoundTripper, ch chan<- model.Event, defaultHost string, interceptedTLS bool) (*http.Response, model.Request, *bodyPreview, error) {
start := time.Now()
request := model.Request{
ID: uuid.New(),
ResponseData: []byte{},
RequestData: []byte{},
URL: "",
Status: -1,
Method: "",
Duration: 0,
Pending: true,
Failed: false,
StartTime: start,
}
outReq := req.Clone(req.Context())
outReq.RequestURI = ""
if interceptedTLS {
if outReq.URL.Scheme == "" {
outReq.URL.Scheme = "https"
}
if outReq.URL.Host == "" {
outReq.URL.Host = defaultHost
}
if outReq.Host == "" {
outReq.Host = defaultHost
}
}
stripHopByHopHeaders(outReq.Header)
requestPreview := newBodyPreview(outReq.Header.Get("Content-Type"))
if outReq.Body != nil {
outReq.Body = &previewReadCloser{ReadCloser: outReq.Body, preview: requestPreview}
}
request.URL = outReq.URL.Path
request.QueryString = outReq.URL.RawQuery
request.QueryMap = outReq.URL.Query()
request.Host = outReq.Host
request.Method = outReq.Method
request.RequestHeaders = redactHeaders(outReq.Header)
request.RawURL = outReq.URL.String()
if request.RawURL == "" {
request.RawURL = outReq.Host + outReq.URL.RequestURI()
}
startRequest(ch, request)
resp, err := transport.RoundTrip(outReq)
request.RequestData = requestPreview.Preview()
if err != nil {
return resp, request, nil, err
}
stripHopByHopHeaders(resp.Header)
responsePreview := newBodyPreview(resp.Header.Get("Content-Type"))
if resp.Body != nil {
resp.Body = &previewReadCloser{ReadCloser: resp.Body, preview: responsePreview}
}
request.ResponseHeaders = redactHeaders(resp.Header)
return resp, request, responsePreview, nil
}
func newConnectRequest(req *http.Request, start time.Time) model.Request {
// CONNECT requests do not have as much data, which is why we use Host for most of the pieces
return model.Request{
ID: uuid.New(),
ResponseData: []byte{},
RequestData: []byte{},
URL: req.Host,
RawURL: req.Host,
Host: req.Host,
Status: -1,
Method: req.Method,
Duration: 0,
Pending: true,
Failed: false,
StartTime: start,
}
}
func finishRequest(ch chan<- model.Event, request model.Request, status int) {
request.Pending = false
request.Failed = false
request.Status = status
request.Duration = time.Since(request.StartTime).Round(time.Microsecond)
ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeRequestFinished,
Body: fmt.Sprintf("(%s) %s %s %d %dms", getEndOfUUID(request.ID), request.Method, request.RawURL, request.Status, request.Duration.Milliseconds()),
Request: request,
}
}
func failRequest(ch chan<- model.Event, request model.Request, status int, body string) {
request.Pending = false
request.Failed = true
request.Status = status
request.Duration = time.Since(request.StartTime).Round(time.Microsecond)
ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeRequestFailed,
Body: fmt.Sprintf("(%s) %s", getEndOfUUID(request.ID), body),
Request: request,
}
}
func startRequest(ch chan<- model.Event, request model.Request) {
ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeRequestStarted,
Body: fmt.Sprintf("(%s) %s %s", getEndOfUUID(request.ID), request.Method, request.RawURL),
Request: request,
}
}

View File

@ -1,30 +0,0 @@
package proxy
import (
"bufio"
"io"
"net"
)
const maxDiscardBodyBytes = 1 << 20
func writeConnectEstablished(conn net.Conn, readWriter *bufio.ReadWriter) error {
if readWriter != nil {
if _, err := readWriter.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
return err
}
return readWriter.Flush()
}
_, err := conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
return err
}
func discardAndCloseBody(body io.ReadCloser) {
if body == nil {
return
}
_, _ = io.Copy(io.Discard, io.LimitReader(body, maxDiscardBodyBytes))
_ = body.Close()
}

View File

@ -10,22 +10,7 @@ import (
"termtap.dev/internal/model" "termtap.dev/internal/model"
) )
const (
proxyReadHeaderTimeout = 10 * time.Second
proxyIdleTimeout = 30 * time.Second
)
func NewProxyServer(addr string, ch chan<- model.Event) (*model.ProxyServer, error) { func NewProxyServer(addr string, ch chan<- model.Event) (*model.ProxyServer, error) {
ca, err := loadOrCreateCertificateAuthority()
if err != nil {
return nil, err
}
trusted, err := ca.IsTrustedBySystem()
if err != nil {
trusted = false
}
listener, err := net.Listen("tcp", addr) listener, err := net.Listen("tcp", addr)
if err != nil { if err != nil {
return nil, err return nil, err
@ -35,17 +20,8 @@ func NewProxyServer(addr string, ch chan<- model.Event) (*model.ProxyServer, err
ps := &model.ProxyServer{ ps := &model.ProxyServer{
Listener: &listener, Listener: &listener,
Server: &http.Server{Handler: proxyHandler(ch)},
Url: url, Url: url,
CACertPath: ca.CertPath(),
CAReady: true,
CACreated: ca.WasCreated(),
CATrusted: trusted,
Conns: make(map[net.Conn]struct{}),
}
ps.Server = &http.Server{
Handler: proxyHandler(ch, ca, ps),
ReadHeaderTimeout: proxyReadHeaderTimeout,
IdleTimeout: proxyIdleTimeout,
} }
return ps, nil return ps, nil
@ -57,53 +33,11 @@ func Destroy(ps *model.ProxyServer, ch chan<- model.Event) {
defer cancel() defer cancel()
if ps != nil && ps.Server != nil { if ps != nil && ps.Server != nil {
closeTrackedConnections(ps)
_ = ps.Server.Shutdown(ctx) _ = ps.Server.Shutdown(ctx)
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(), Time: time.Now().Local(),
Type: model.EventTypeProxyStopped, Type: model.EventTypeProxyStarted,
Body: "proxy server was destroyed", Body: "proxy server was destroyed",
} }
} }
} }
func trackConnection(ps *model.ProxyServer, conn net.Conn) {
if ps == nil || conn == nil {
return
}
ps.ConnMu.Lock()
defer ps.ConnMu.Unlock()
ps.Conns[conn] = struct{}{}
}
func untrackConnection(ps *model.ProxyServer, conn net.Conn) {
if ps == nil || conn == nil {
return
}
ps.ConnMu.Lock()
defer ps.ConnMu.Unlock()
delete(ps.Conns, conn)
}
func closeTrackedConnections(ps *model.ProxyServer) {
if ps == nil {
return
}
// Get all of the connections while claiming the mutex.
// Then close the mutex to allow access to the server object quicker.
// Then a loop can run to close the connections, without needing access
// to the server's mutex.
ps.ConnMu.Lock()
conns := make([]net.Conn, 0, len(ps.Conns))
for conn := range ps.Conns {
conns = append(conns, conn)
}
ps.ConnMu.Unlock()
for _, conn := range conns {
_ = conn.Close()
}
}

View File

@ -1,111 +0,0 @@
package proxy
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"sort"
"strings"
"github.com/google/uuid"
)
var validContentTypes = []string{
"application/graphql",
"application/javascript",
"application/json",
"application/x-www-form-urlencoded",
"application/xml",
"+json",
"+xml",
}
func canDisplayContent(contentType string) bool {
if contentType == "" {
return false
}
contentType = strings.ToLower(contentType)
if strings.HasPrefix(contentType, "text/") {
return true
}
for _, t := range validContentTypes {
if strings.Contains(contentType, t) {
return true
}
}
return false
}
// NOTE: Currently unused, will be reference for the future header rendering
func formatHeaders(headers http.Header) string {
if len(headers) == 0 {
return "<none>"
}
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, ", ")
}
func getEndOfUUID(id uuid.UUID) string {
return id.String()[24:]
}
// BUG: Not sure if this actually works, seems to favor the 502
func statusFromUpstreamError(req *http.Request, resp *http.Response, err error) int {
if resp != nil {
return resp.StatusCode
}
if errors.Is(req.Context().Err(), context.Canceled) {
return http.StatusBadGateway
}
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusGatewayTimeout
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return http.StatusGatewayTimeout
}
return http.StatusBadGateway
}
func newUpstreamTransport() http.RoundTripper {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
return transport
}
func writePlainHTTPError(w *bufio.Writer, status int) error {
resp := &http.Response{
StatusCode: status,
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(http.StatusText(status))),
ContentLength: int64(len(http.StatusText(status))),
Close: false,
}
resp.Header.Set("Content-Type", "text/plain; charset=utf-8")
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(http.StatusText(status))))
if err := resp.Write(w); err != nil {
return err
}
return w.Flush()
}

View File

@ -23,7 +23,7 @@ func (m Model) renderStatusBar(w int) string {
avg := int(msSum) / max(1, len(m.requests)) avg := int(msSum) / max(1, len(m.requests))
left := fmt.Sprintf(" tap %3d reqs | %d err | avg %dms", len(m.requests), errCount, avg) left := fmt.Sprintf(" tap %3d reqs | %d err | avg %dms", len(m.requests), errCount, avg)
right := "j/k nav / search tab panel e events o output ^r restart q quit " right := "j/k nav / search tab panel e events o output r/^r restart q quit "
spaceSize := max(w-(len(left)+len(right)), 0) spaceSize := max(w-(len(left)+len(right)), 0)
space := strings.Repeat(" ", spaceSize) space := strings.Repeat(" ", spaceSize)
@ -57,7 +57,7 @@ func (m Model) renderRequestPane(w, h int) []string {
left := fmt.Sprintf( left := fmt.Sprintf(
" %-7s %-24s %s", " %-7s %-24s %s",
strings.ToUpper(req.Method), strings.ToUpper(req.Method),
truncate(req.Host, 24), req.Host,
req.URL, req.URL,
) )
right := fmt.Sprintf( right := fmt.Sprintf(
@ -98,7 +98,6 @@ func (m Model) renderDetailsPane(w, h int) []string {
for y := range lines { for y := range lines {
lines[y] = m.theme.Text.Render(strings.Repeat(" ", w)) lines[y] = m.theme.Text.Render(strings.Repeat(" ", w))
} }
return lines return lines
} }

View File

@ -27,7 +27,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "ctrl+c", "q": case "ctrl+c", "q":
return m, tea.Quit return m, tea.Quit
case tea.KeyCtrlR.String(): case "r", tea.KeyCtrlR.String():
if m.restarting { if m.restarting {
return m, nil return m, nil
} }
@ -95,10 +95,6 @@ func (m *Model) applyMessage(msg model.Event) {
} }
func (m *Model) createRequest(req model.Request) { func (m *Model) createRequest(req model.Request) {
if req.Method == "CONNECT" {
return
}
m.requests = append(m.requests, req) m.requests = append(m.requests, req)
// If we passed the max, delete the first one // If we passed the max, delete the first one