From 58d19f8bb67ec793abe347c4ba53fc3ef96b1b48 Mon Sep 17 00:00:00 2001 From: Mikhail Shamne Date: Mon, 21 Sep 2026 17:22:01 +0300 Subject: [PATCH] feat: support CA-free proxying and toggle TLS interception in the TUI --- README.md | 20 +++ README.ru.md | 22 +++ docs/guide.md | 25 ++++ docs/guide.ru.md | 26 ++++ internal/proxy/local.go | 52 ++++++- internal/proxy/proxy.go | 73 ++++++++-- internal/proxy/proxy_test.go | 256 ++++++++++++++++++++++++++++++++++- internal/tui/app.go | 28 ++++ internal/tui/app_test.go | 67 +++++++++ internal/tui/views.go | 34 +++++ main.go | 36 +++-- main_test.go | 60 +++++++- 12 files changed, 670 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index f8e0c87..6f5dbfc 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,26 @@ git clone https://github.com/sadgoodman/cli-proxy.git cd cli-proxy && make build && ./cli-proxy ``` +## Proxy without TLS interception + +```sh +./cli-proxy -tunnel # terminal UI +./cli-proxy -tunnel -headless # no UI +./cli-proxy -tunnel -system-proxy # also enable the system proxy +curl -x http://127.0.0.1:8080 https://example.com +``` + +No CA certificate is created or required. HTTPS passes through CONNECT without +decryption: clients see the original server certificate, and only connection +endpoints and byte counts are recorded. Rules and breakpoints do not apply to +HTTPS contents. Plain HTTP capture and editing remain available. TLS interception +is still the default when `-tunnel` is omitted. + +To switch while running, open **Cert** (`4`) and press `m` or click +**TLS off / TLS on**. Changes apply to new connections; reconnect existing +clients to use the new mode. Enabling interception creates the CA if needed +but does not automatically install it. + ## Documentation The full reference — every flag, the rule DSL, filter syntax, key bindings, diff --git a/README.ru.md b/README.ru.md index b8dc1b2..8d2351d 100644 --- a/README.ru.md +++ b/README.ru.md @@ -92,6 +92,28 @@ git clone https://github.com/sadgoodman/cli-proxy.git cd cli-proxy && make build && ./cli-proxy ``` +## Прокси без перехвата TLS + +Для работы без перехвата SSL/TLS используйте существующий режим `-tunnel`: + +```sh +./cli-proxy -tunnel # с терминальным интерфейсом +./cli-proxy -tunnel -headless # без интерфейса +./cli-proxy -tunnel -system-proxy # также включить системный прокси +curl -x http://127.0.0.1:8080 https://example.com +``` + +CA-сертификат не создаётся и не требуется. HTTPS передаётся через CONNECT +без расшифровки: клиент получает исходный сертификат сервера, а в журнале +видны только адрес соединения и объём переданных данных. Правила и breakpoints +не применяются к содержимому HTTPS. Обычный HTTP по-прежнему доступен для +просмотра и изменения. По умолчанию, без `-tunnel`, включён перехват TLS. + +Переключить режим во время работы можно во вкладке **Cert** (`4`): клавиша +`m` или кнопка **TLS off / TLS on**. Изменение действует на новые соединения; +для уже открытых требуется переподключить клиент. При первом включении +перехвата CA создаётся при необходимости, но автоматически не устанавливается. + ## Документация Полное руководство — все флаги, синтаксис правил и фильтров, горячие клавиши, diff --git a/docs/guide.md b/docs/guide.md index a694461..e4358fe 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -161,6 +161,31 @@ Checking it from the same machine: curl -x http://127.0.0.1:8080 --cacert ~/.cli-proxy/ca.pem https://example.com ``` +### Proxy without SSL/TLS interception + +Run `./cli-proxy -tunnel`, or `./cli-proxy -tunnel -headless` without the TUI. +This mode does not load or generate a CA, and clients need no proxy certificate. +To check it: + +```sh +curl -x http://127.0.0.1:8080 https://example.com +``` + +HTTPS passes through an opaque CONNECT tunnel with the original server certificate. +Only endpoints and byte counts are visible, not HTTPS URL paths, headers or bodies. +Rules and breakpoints do not apply inside tunnels. Plain HTTP works as before. +You can combine `-tunnel` with `-system-proxy`. Standalone `-install-cert` and +`-uninstall-cert` commands still perform their explicit certificate action even +when combined with `-tunnel`. + +In the TUI, open **Cert** (`4`) and press `m` or click **TLS off / TLS on**. +The header (`MITM` / `TUNNEL`) and Cert view show the current mode. Changes +affect new connections only; existing connections retain their mode until +the client reconnects. Enabling interception loads or creates the CA, while +trust-store installation remains a separate action. If loading the CA fails, +the proxy stays in tunnel mode. The selection lasts for the current run; +the next startup uses the `-tunnel` flag again. + ## Changing the port You can set the port with a flag, or change it right in the running interface: diff --git a/docs/guide.ru.md b/docs/guide.ru.md index 7ce5dfd..1bf1a91 100644 --- a/docs/guide.ru.md +++ b/docs/guide.ru.md @@ -157,6 +157,31 @@ GOOS=windows GOARCH=amd64 go build -o cli-proxy.exe . curl -x http://127.0.0.1:8080 --cacert ~/.cli-proxy/ca.pem https://example.com ``` +### Прокси без перехвата SSL/TLS + +Запустите `./cli-proxy -tunnel` (или `./cli-proxy -tunnel -headless` без TUI). +В этом режиме CA не загружается и не создаётся; устанавливать сертификат +на клиентские устройства не нужно. Проверка: + +```sh +curl -x http://127.0.0.1:8080 https://example.com +``` + +HTTPS проходит через непрозрачный CONNECT-туннель с исходным сертификатом +сервера. Видны адресаты и объёмы данных, но не URL-пути, заголовки или тела +HTTPS-запросов. Правила и breakpoints внутри туннеля не работают. Обычный HTTP +обрабатывается как прежде. Флаг `-system-proxy` можно сочетать с `-tunnel`. +Отдельные команды `-install-cert` и `-uninstall-cert` по-прежнему выполняют +явно запрошенное действие с сертификатом, даже при указании `-tunnel`. + +В TUI откройте **Cert** (`4`) и нажмите `m` или кнопку **TLS off / TLS on**. +Текущий режим показан в шапке (`MITM` / `TUNNEL`) и во вкладке Cert. +Переключение действует только на новые соединения: существующие продолжают +работать в прежнем режиме до переподключения клиента. При первом включении +перехвата CA загружается или создаётся; установка в доверенные остаётся +отдельным действием. При ошибке загрузки CA сохраняется режим туннеля. +Выбор действует до выхода; при следующем запуске режим задаёт флаг `-tunnel`. + ## Смена порта Порт можно задать флагом, а можно поменять прямо в работающем интерфейсе: @@ -390,6 +415,7 @@ filter`), а в заголовке списка — `+2 saved`. Фильтры | `I` (в разделе Cert) | то же, но для всех пользователей | | `u` (в разделе Cert) | убрать сертификат из доверенных | | `s` (в разделе Cert) | включить/выключить системный прокси этой машины | +| `m` (в разделе Cert) | включить/выключить перехват TLS для новых соединений | | `c` | очистить список | | `Tab` / `Shift-Tab` | активная панель / таб панели (см. выше) | | `h` `b` `r` | табы панели: заголовки, тело, сырое сообщение | diff --git a/internal/proxy/local.go b/internal/proxy/local.go index 89525d8..49ffebd 100644 --- a/internal/proxy/local.go +++ b/internal/proxy/local.go @@ -5,6 +5,7 @@ import ( "encoding/json" "encoding/pem" "fmt" + "html" "net" "net/http" "sort" @@ -84,6 +85,14 @@ func (p *Proxy) serveLocal(w http.ResponseWriter, r *http.Request, origin string path = "/" } p.cfg.Log.Addf("%s request %s %s from %s", origin, r.Method, path, r.RemoteAddr) + if p.CA() == nil { + switch path { + case "/", "/index.html", "/help", "/status", "/status.json": + default: + http.Error(w, "not found; TLS pass-through is enabled, no CA certificate is available", http.StatusNotFound) + return + } + } switch path { case "/", "/index.html", "/help": w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -94,9 +103,9 @@ func (p *Proxy) serveLocal(w http.ResponseWriter, r *http.Request, origin string w.Header().Set("Content-Type", "application/x-x509-ca-cert") w.Header().Set("Content-Disposition", `attachment; filename="cli-proxy-ca.crt"`) w.Header().Set("Cache-Control", "no-store") - _, _ = w.Write(p.cfg.CA.CertPEM) + _, _ = w.Write(p.CA().CertPEM) case "/cert.der", "/ca.der": - block, _ := pem.Decode(p.cfg.CA.CertPEM) + block, _ := pem.Decode(p.CA().CertPEM) if block == nil { http.Error(w, "corrupt CA", http.StatusInternalServerError) return @@ -110,6 +119,10 @@ func (p *Proxy) serveLocal(w http.ResponseWriter, r *http.Request, origin string _, _ = w.Write([]byte(p.mobileconfig())) case "/status", "/status.json": active, total := p.Stats() + fingerprint := "" + if p.CA() != nil { + fingerprint = p.CA().Fingerprint() + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "proxy": p.Addr(), @@ -121,7 +134,7 @@ func (p *Proxy) serveLocal(w http.ResponseWriter, r *http.Request, origin string "breakpoints": p.cfg.Breaker.Len(), "active_conns": active, "total_conns": total, - "ca_fingerprint": p.cfg.CA.Fingerprint(), + "ca_fingerprint": fingerprint, }) default: http.Error(w, "not found\n\navailable: / /ssl /cert /cert.der /ca.mobileconfig /status", http.StatusNotFound) @@ -143,6 +156,9 @@ func (p *Proxy) BaseURL() string { // DeviceHint returns the short "point your phone here" instruction. func (p *Proxy) DeviceHint() string { + if p.tunnelOnly.Load() { + return fmt.Sprintf("proxy %s | TLS pass-through; no CA certificate required", p.DisplayAddr()) + } return fmt.Sprintf("proxy %s | cert %s/cert", p.DisplayAddr(), p.BaseURL()) } @@ -217,6 +233,23 @@ func (p *Proxy) CertSteps() string { if len(ips) > 0 { host = ips[0] } + if p.tunnelOnly.Load() { + return fmt.Sprintf(`TLS pass-through + +HTTPS is forwarded through CONNECT without TLS interception. +No CA certificate installation is required. +Only CONNECT endpoints and byte counts are visible for HTTPS. +Plain HTTP capture, rules and breakpoints still work. + +Configure your device's HTTP and HTTPS proxy: + Server: %s Port: %s + +For programs on this computer: + export HTTP_PROXY=http://127.0.0.1:%s + export HTTPS_PROXY=http://127.0.0.1:%s + curl -x http://127.0.0.1:%s https://example.com +`, host, port, port, port, port) + } return fmt.Sprintf(certStepsText, "http://"+LocalHostName, // 1 short base p.BaseURL()+"/cert", // 2 full certificate URL @@ -227,6 +260,15 @@ func (p *Proxy) CertSteps() string { } func (p *Proxy) indexHTML() string { + if p.tunnelOnly.Load() { + return ` + +cli-proxy +

cli-proxy

Mode: tunnel

+

Proxy address: ` + html.EscapeString(p.DisplayAddr()) + `

+
` + html.EscapeString(p.CertSteps()) + `
+` + } _, port, _ := net.SplitHostPort(p.Addr()) ips := LocalIPs() var hosts strings.Builder @@ -257,7 +299,7 @@ func (p *Proxy) indexHTML() string {
Proxy address: ` + p.DisplayAddr() + `
LAN addresses: ` + hosts.String() + `
Mode: ` + p.Mode() + `
-
CA: ` + p.cfg.CA.Summary() + `
+
CA: ` + p.CA().Summary() + `

Short URL

Any device whose proxy already points here can use @@ -279,7 +321,7 @@ curl -x ` + p.BaseURL() + ` --cacert cli-proxy-ca.crt https://example.com func (p *Proxy) mobileconfig() string { uuid := "cli-proxy-ca-root" - block, _ := pem.Decode(p.cfg.CA.CertPEM) + block, _ := pem.Decode(p.CA().CertPEM) der := "" if block != nil { der = base64Std(block.Bytes) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a4c9e74..914760f 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -37,7 +37,7 @@ var hopHeaders = []string{ // Config configures a Proxy instance. type Config struct { Addr string - TunnelOnly bool // never MITM, just pass CONNECT through + TunnelOnly bool // initially pass CONNECT through without MITM MaxBody int64 CA *ca.CA Store *core.Store @@ -68,6 +68,10 @@ type Proxy struct { conns map[net.Conn]struct{} started time.Time + modeMu sync.Mutex + tunnelOnly atomic.Bool + authority atomic.Pointer[ca.CA] + activeConns atomic.Int64 totalConns atomic.Int64 } @@ -106,11 +110,14 @@ func New(cfg Config) *Proxy { ExpectContinueTimeout: 2 * time.Second, DisableCompression: true, } - return &Proxy{ + p := &Proxy{ cfg: cfg, transport: tr, conns: make(map[net.Conn]struct{}), } + p.tunnelOnly.Store(cfg.TunnelOnly) + p.authority.Store(cfg.CA) + return p } // Start binds the listener and begins serving in the background. @@ -259,7 +266,7 @@ func loadCABundle(path string) (*x509.CertPool, error) { } // CA exposes the certificate authority used for TLS interception. -func (p *Proxy) CA() *ca.CA { return p.cfg.CA } +func (p *Proxy) CA() *ca.CA { return p.authority.Load() } // CAPath returns the on-disk directory holding the CA material. func (p *Proxy) CAPath() string { return p.cfg.CAPath } @@ -276,12 +283,37 @@ func (p *Proxy) Addr() string { // Mode returns the interception mode. func (p *Proxy) Mode() string { - if p.cfg.TunnelOnly { + if p.tunnelOnly.Load() { return "tunnel" } return "mitm" } +// SetTunnelOnly changes how new CONNECT connections are handled. Existing +// connections retain their mode. The CA is loaded only when interception is +// first enabled and retained so active TLS sessions can finish normally. +func (p *Proxy) SetTunnelOnly(enabled bool) error { + p.modeMu.Lock() + defer p.modeMu.Unlock() + if p.tunnelOnly.Load() == enabled { + return nil + } + if !enabled && p.CA() == nil { + dir := p.CAPath() + if dir == "" { + dir = ca.Dir() + } + authority, err := ca.Load(dir) + if err != nil { + return fmt.Errorf("certificate authority: %w", err) + } + p.authority.Store(authority) + } + p.tunnelOnly.Store(enabled) + p.cfg.Log.Addf("proxy mode changed to %s for new connections", p.Mode()) + return nil +} + // Started returns the start time. func (p *Proxy) Started() time.Time { p.mu.Lock() @@ -398,6 +430,12 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { host = net.JoinHostPort(host, "443") } + // Opaque tunnels must also support protocols where the server speaks first. + if p.tunnelOnly.Load() { + p.tunnel(conn, brw.Reader, host, r, false) + return + } + // Acknowledge the tunnel before doing anything else. if _, err := conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil { _ = conn.Close() @@ -412,8 +450,8 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { return } - if p.cfg.TunnelOnly || first[0] != 0x16 { // 0x16 = TLS handshake record - p.tunnel(conn, brw.Reader, host, r) + if first[0] != 0x16 { // 0x16 = TLS handshake record + p.tunnel(conn, brw.Reader, host, r, true) return } @@ -421,7 +459,8 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { } // tunnel blindly forwards bytes in both directions (non-TLS or tunnel mode). -func (p *Proxy) tunnel(client net.Conn, buffered *bufio.Reader, host string, r *http.Request) { +func (p *Proxy) tunnel(client net.Conn, buffered *bufio.Reader, host string, r *http.Request, acknowledged bool) { + defer client.Close() start := time.Now() flow := p.cfg.Store.Add(&core.Flow{ Start: start, @@ -441,19 +480,33 @@ func (p *Proxy) tunnel(client net.Conn, buffered *bufio.Reader, host string, r * if err != nil { p.finishFlow(flow.ID, func(f *core.Flow) { f.State = core.StateError + f.Status = http.StatusBadGateway f.Err = err.Error() f.End = time.Now() f.Duration = f.End.Sub(f.Start) }) - _ = client.Close() + if !acknowledged { + _, _ = io.WriteString(client, "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + } return } defer upstream.Close() + if !acknowledged { + if _, err := io.WriteString(client, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + p.finishFlow(flow.ID, func(f *core.Flow) { + f.State = core.StateError + f.Err = err.Error() + f.End = time.Now() + f.Duration = f.End.Sub(f.Start) + }) + return + } + } + p.finishFlow(flow.ID, func(f *core.Flow) { f.Status = 200 f.Reason = "Connection Established" - f.AddTag("tls") }) var in, out atomic.Int64 @@ -502,7 +555,7 @@ func (p *Proxy) mitm(client net.Conn, buffered *bufio.Reader, host string, r *ht if name == "" { name = host } - return p.cfg.CA.Leaf(name) + return p.CA().Leaf(name) }, } tlsConn := tls.Server(&bufferedConn{Conn: client, r: buffered}, tlsCfg) diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index c5a3557..45692f7 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -1,6 +1,8 @@ package proxy import ( + "bufio" + "bytes" "crypto/tls" "crypto/x509" "encoding/json" @@ -10,8 +12,10 @@ import ( "net/http/httptest" "net/url" "os" + "path/filepath" "regexp" "strings" + "sync" "testing" "time" @@ -516,16 +520,25 @@ func TestGlobalBreakpointToggles(t *testing.T) { } func TestTunnelMode(t *testing.T) { - h := newHarness(t, func(cfg *Config) { cfg.TunnelOnly = true }) + h := newHarness(t, func(cfg *Config) { + cfg.TunnelOnly = true + cfg.CA = nil + cfg.InsecureUpstream = false + }) // Without interception the client sees the origin certificate itself, so // it must not expect our CA to have signed anything. + pool := x509.NewCertPool() + pool.AddCert(h.secure.Certificate()) tunnelClient := &http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ - Proxy: http.ProxyURL(h.proxyURL), - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + Proxy: http.ProxyURL(h.proxyURL), + TLSClientConfig: &tls.Config{RootCAs: pool}, + DisableKeepAlives: true, }, } + defer tunnelClient.CloseIdleConnections() + addRule(t, h.rules, "* .* :: block=403") resp, err := tunnelClient.Get(h.secure.URL + "/tunnelled") if err != nil { t.Fatalf("GET through tunnel: %v", err) @@ -534,16 +547,253 @@ func TestTunnelMode(t *testing.T) { if resp.StatusCode != 200 { t.Fatalf("status = %d", resp.StatusCode) } + if resp.TLS == nil || !bytes.Equal(resp.TLS.PeerCertificates[0].Raw, h.secure.Certificate().Raw) { + t.Fatal("client did not receive the original server certificate") + } + if _, err := io.ReadAll(resp.Body); err != nil { + t.Fatal(err) + } + resp.Body.Close() flows := h.waitFlows(1) found := false for _, f := range flows { if f.Method == "CONNECT" && f.HasTag("tunnel") { found = true + if f.State != core.StateComplete || len(f.ReqBody) != 0 || len(f.RespBody) != 0 { + t.Fatalf("unexpected tunnel capture: %+v", f) + } } } if !found { t.Fatalf("tunnel flow not recorded: %+v", flows) } + if len(flows) != 1 { + t.Fatalf("HTTPS requests should not be captured: %+v", flows) + } + h.rules.Clear() + plainResp, _ := h.get(h.origin.URL + "/plain") + if plainResp.StatusCode != http.StatusOK { + t.Fatalf("plain HTTP status = %d", plainResp.StatusCode) + } +} + +func TestChangeTLSModeKeepsExistingConnections(t *testing.T) { + h := newHarness(t, func(cfg *Config) { + cfg.TunnelOnly, cfg.CA = true, nil + cfg.CAPath = t.TempDir() + }) + pool := x509.NewCertPool() + pool.AddCert(h.secure.Certificate()) + tunnelTransport := &http.Transport{ + Proxy: http.ProxyURL(h.proxyURL), TLSClientConfig: &tls.Config{RootCAs: pool}, + } + t.Cleanup(tunnelTransport.CloseIdleConnections) + tunnelClient := &http.Client{Transport: tunnelTransport, Timeout: 5 * time.Second} + check := func(client *http.Client, cert *x509.Certificate) { + t.Helper() + resp, err := client.Get(h.secure.URL + "/mode") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if _, err := io.ReadAll(resp.Body); err != nil { + t.Fatal(err) + } + if resp.TLS == nil || !bytes.Equal(resp.TLS.PeerCertificates[0].Raw, cert.Raw) { + t.Fatal("connection used the wrong TLS mode") + } + } + check(tunnelClient, h.secure.Certificate()) + if err := h.px.SetTunnelOnly(false); err != nil { + t.Fatal(err) + } + if h.px.Mode() != "mitm" || h.px.CA() == nil { + t.Fatal("interception did not initialize its CA") + } + check(tunnelClient, h.secure.Certificate()) // existing tunnel survives + mitmTransport := &http.Transport{ + Proxy: http.ProxyURL(h.proxyURL), TLSClientConfig: &tls.Config{RootCAs: poolWith(h.px)}, + } + t.Cleanup(mitmTransport.CloseIdleConnections) + mitmClient := &http.Client{Transport: mitmTransport, Timeout: 5 * time.Second} + leaf, err := h.px.CA().Leaf("127.0.0.1") + if err != nil { + t.Fatal(err) + } + check(mitmClient, leaf.Leaf) + if err := h.px.SetTunnelOnly(true); err != nil { + t.Fatal(err) + } + check(mitmClient, leaf.Leaf) // existing intercepted connection survives + tunnelTransport.CloseIdleConnections() + check(tunnelClient, h.secure.Certificate()) // new connection is opaque again +} + +func TestEnableTLSFailureKeepsTunnelMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(path, []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + p := New(Config{TunnelOnly: true, CAPath: path}) + if err := p.SetTunnelOnly(false); err == nil { + t.Fatal("expected a CA initialization error") + } + if p.Mode() != "tunnel" || p.CA() != nil { + t.Fatal("failed initialization changed the mode") + } +} + +func TestTLSModeConcurrentLocalRequests(t *testing.T) { + h := newHarness(t, func(cfg *Config) { + cfg.TunnelOnly, cfg.CA = true, nil + cfg.CAPath = t.TempDir() + }) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for range 30 { + for _, path := range []string{"/", "/status", "/cert"} { + w := httptest.NewRecorder() + h.px.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != http.StatusOK && w.Code != http.StatusNotFound { + t.Errorf("%s returned %d", path, w.Code) + } + } + } + }() + defer wg.Wait() + for i := range 30 { + if err := h.px.SetTunnelOnly(i%2 != 0); err != nil { + t.Fatal(err) + } + } +} + +func TestTunnelRawCONNECT(t *testing.T) { + h := newHarness(t, func(cfg *Config) { + cfg.TunnelOnly, cfg.CA = true, nil + }) + for _, serverFirst := range []bool{true, false} { + name := "buffered-client-data" + if serverFirst { + name = "server-first" + } + t.Run(name, func(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + if serverFirst { + _, _ = io.WriteString(conn, "hello\n") + } else { + _, _ = io.Copy(conn, conn) + } + }() + client, err := net.DialTimeout("tcp", h.px.Addr(), 5*time.Second) + if err != nil { + t.Fatal(err) + } + defer client.Close() + _ = client.SetDeadline(time.Now().Add(5 * time.Second)) + request := "CONNECT " + ln.Addr().String() + " HTTP/1.1\r\nHost: " + ln.Addr().String() + "\r\n\r\n" + if !serverFirst { + request += "hello\n" + } + if _, err := io.WriteString(client, request); err != nil { + t.Fatal(err) + } + reader := bufio.NewReader(client) + resp, err := http.ReadResponse(reader, &http.Request{Method: http.MethodConnect}) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("CONNECT status = %d", resp.StatusCode) + } + if got, err := reader.ReadString('\n'); err != nil || got != "hello\n" { + t.Fatalf("tunnel data = %q, err = %v", got, err) + } + }) + } +} + +func TestTunnelUnreachableOrigin(t *testing.T) { + h := newHarness(t, func(cfg *Config) { + cfg.TunnelOnly, cfg.CA = true, nil + }) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + target := ln.Addr().String() + ln.Close() + client, err := net.DialTimeout("tcp", h.px.Addr(), 5*time.Second) + if err != nil { + t.Fatal(err) + } + defer client.Close() + _ = client.SetDeadline(time.Now().Add(5 * time.Second)) + _, _ = io.WriteString(client, "CONNECT "+target+" HTTP/1.1\r\nHost: "+target+"\r\n\r\n") + resp, err := http.ReadResponse(bufio.NewReader(client), &http.Request{Method: http.MethodConnect}) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("CONNECT status = %d, want 502", resp.StatusCode) + } + flows := h.waitFlows(1) + if len(flows) != 1 || flows[0].State != core.StateError || flows[0].Status != 502 { + t.Fatalf("expected failed tunnel flow: %+v", flows) + } +} + +func TestTunnelLocalPagesWithoutCA(t *testing.T) { + h := newHarness(t, func(cfg *Config) { + cfg.TunnelOnly, cfg.CA = true, nil + }) + for _, path := range []string{"/", "/help", "/status", "/cert", "/ssl", "/cert.der", "/ca.mobileconfig"} { + t.Run(path, func(t *testing.T) { + resp, err := http.Get("http://" + h.px.Addr() + path) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + want := http.StatusNotFound + switch path { + case "/", "/help": + want = http.StatusOK + if !strings.Contains(string(body), "No CA certificate installation is required") || strings.Contains(string(body), "href=\"/cert\"") { + t.Fatalf("unexpected tunnel instructions: %s", body) + } + case "/status": + want = http.StatusOK + var status map[string]any + if err := json.Unmarshal(body, &status); err != nil { + t.Fatal(err) + } + if status["mode"] != "tunnel" || status["ca_fingerprint"] != "" { + t.Fatalf("unexpected status: %s", body) + } + } + if resp.StatusCode != want { + t.Fatalf("status = %d, want %d", resp.StatusCode, want) + } + }) + } } func TestLocalCertificateEndpoint(t *testing.T) { diff --git a/internal/tui/app.go b/internal/tui/app.go index 0660956..82312cb 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -757,6 +757,9 @@ func (a *App) handleGlobalKey(k term.Key) { if k.Is("o") && !k.Ctrl { a.openInBrowser() } + if k.Is("m") && !k.Ctrl { + a.toggleTLSInterception() + } if k.Is("p") && !k.Ctrl { a.openPortPrompt() } @@ -1442,6 +1445,27 @@ func (a *App) toggleSystemProxy() { a.status("system proxy -> "+a.sysProxy.Target()+" (restored when cli-proxy exits)", 1) } +// toggleTLSInterception only affects new connections; existing sessions keep +// their mode until the client reconnects. +func (a *App) toggleTLSInterception() { + tunnel := a.proxy.Mode() != "tunnel" + if err := a.proxy.SetTunnelOnly(tunnel); err != nil { + a.status("cannot change TLS mode: "+err.Error(), 2) + return + } + a.certScroll = 0 + if tunnel { + a.status("TLS interception OFF for new connections; reconnect clients to apply", 1) + return + } + if a.trust == nil { + authority := a.proxy.CA() + a.trust = trust.New(authority.CertPath(), authority.CommonName()) + } + a.refreshTrust() + a.status("TLS interception ON for new connections; clients must trust the CA", 1) +} + // refreshTrust re-reads the operating system's trust state. func (a *App) refreshTrust() { a.trustChecked = time.Now() @@ -1493,6 +1517,10 @@ func (a *App) uninstallCert() { } func (a *App) certReady() bool { + if a.proxy.Mode() == "tunnel" { + a.status("TLS pass-through; no CA certificate required", 0) + return false + } if a.trust == nil || !a.trust.Available() { a.status("certificate installation is not available on this platform", 2) return false diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 7a98d06..033745d 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -24,6 +24,73 @@ func newTestApp(t *testing.T) *App { return newTestAppAt(t, "127.0.0.1:8080") } +func TestTunnelCertViewWithoutCA(t *testing.T) { + a := newTestApp(t) + a.proxy = proxy.New(proxy.Config{ + Addr: "127.0.0.1:8080", TunnelOnly: true, + Store: a.store, Rules: a.ruleset, Breaker: a.brk, Opts: a.opts, + }) + a.view = ViewCert + a.render() + var rendered strings.Builder + for y := 0; y < a.H; y++ { + rendered.WriteString(rowText(a.screen, y)) + } + if !strings.Contains(rendered.String(), "No CA certificate installation is required") { + t.Fatal("tunnel certificate view missing pass-through instructions") + } + if strings.Contains(rendered.String(), "install cert") { + t.Fatal("tunnel view offers certificate installation") + } + if a.certReady() { + t.Fatal("certificate operations should be disabled in tunnel mode") + } +} + +func TestTLSModeToggleFromCertView(t *testing.T) { + a := newTestApp(t) + fake := &fakeTrust{available: true} + a.trust = fake + a.view = ViewCert + press(a, term.Key{Rune: 'm'}) + if a.proxy.Mode() != "tunnel" { + t.Fatal("m did not disable TLS interception") + } + a.render() + clicked := false + for _, button := range a.lay.buttons { + if button.Key == "m" && button.Label == "TLS on" { + a.handleMouse(term.Mouse{X: button.X, Y: button.row, Press: true}) + clicked = true + break + } + } + if !clicked || a.proxy.Mode() != "mitm" { + t.Fatal("button did not enable TLS interception") + } + if len(fake.installs) != 0 || fake.removes != 0 { + t.Fatal("switching modes must not change the system trust store") + } +} + +func TestTLSModeToggleInitializesCA(t *testing.T) { + a := newTestApp(t) + a.proxy = proxy.New(proxy.Config{ + Addr: "127.0.0.1:8080", TunnelOnly: true, CAPath: t.TempDir(), + Store: a.store, Rules: a.ruleset, Breaker: a.brk, Opts: a.opts, + }) + a.view = ViewCert + press(a, term.Key{Rune: 'm'}) + if a.proxy.Mode() != "mitm" || a.proxy.CA() == nil || a.trust == nil { + t.Fatal("enabling interception did not initialize certificate support") + } + a.render() + press(a, term.Key{Rune: 'm'}) + if a.proxy.Mode() != "tunnel" { + t.Fatal("could not return to tunnel mode") + } +} + func newTestAppAt(t *testing.T, addr string) *App { t.Helper() authority, err := ca.Load(t.TempDir()) diff --git a/internal/tui/views.go b/internal/tui/views.go index 896a5c2..f754753 100644 --- a/internal/tui/views.go +++ b/internal/tui/views.go @@ -1216,6 +1216,25 @@ func (a *App) renderCert(y, h int) { return } + if a.proxy.Mode() == "tunnel" { + a.lay.certTabs = nil + lines := []tline{ + tl("TLS interception OFF [m] enable", t.Accent), + tl("New connections only; reconnect clients after switching.", t.Dim), + tl("proxy "+a.proxy.DisplayAddr(), t.Accent), tl("", t.Base), + } + for _, line := range strings.Split(a.proxy.CertSteps(), "\n") { + lines = append(lines, tl(line, t.Base)) + } + sysState := "off" + if a.sysProxy != nil && a.sysProxy.Active() { + sysState = "on -> " + a.sysProxy.Target() + } + lines = append(lines, tl("System proxy: "+sysState+" [s] toggle", t.Accent)) + a.drawWrapped(rect{X: innerX, Y: innerY, W: innerW, H: innerH}, lines, a.certScroll) + return + } + wide := innerW >= 96 leftW := innerW rightW := 0 @@ -1246,6 +1265,9 @@ func (a *App) renderCert(y, h int) { // ---- left: authority and status --------------------------------------- left := []tline{ + tl("TLS interception ON [m] disable", t.Accent), + tl("New connections only; reconnect clients.", t.Dim), + tl("", t.Base), tl("authority", Style{Fg: 81, Bold: true, Underline: true}), tl("Subject "+cert.Cert.Subject.String(), t.Base), tl("Expires "+cert.Cert.NotAfter.Format("2006-01-02"), t.Base), @@ -1435,6 +1457,7 @@ func helpSections() []helpSection { kv("c", "remove every rule"), }}, {"Certificate", []helpRow{ + kv("m", "toggle TLS interception for new connections"), kv("i / I / u", "install for this user / all / remove"), kv("s", "toggle the system proxy (restored on exit)"), kv("t", "cycle the instruction tab"), @@ -1700,7 +1723,18 @@ func (a *App) currentButtons() []button { {Label: "quit", Key: "q", Action: func() { a.quit = true }}, } case ViewCert: + if a.proxy.Mode() == "tunnel" { + return []button{ + {Label: "TLS on", Key: "m", Action: a.toggleTLSInterception}, + {Label: "sys-proxy", Key: "s", Action: a.toggleSystemProxy}, + {Label: "browser", Key: "o", Action: a.openInBrowser}, + {Label: "port", Key: "p", Action: a.openPortPrompt}, + {Label: "help", Key: "?", Action: func() { a.prevView = a.view; a.view = ViewHelp }}, + {Label: "quit", Key: "q", Action: func() { a.quit = true }}, + } + } return []button{ + {Label: "TLS off", Key: "m", Action: a.toggleTLSInterception}, {Label: "install cert", Key: "i", Action: func() { a.installCert(false) }}, {Label: "system-wide", Key: "I", Action: func() { a.installCert(true) }}, {Label: "remove cert", Key: "u", Action: a.uninstallCert}, diff --git a/main.go b/main.go index a7036a0..1067c42 100644 --- a/main.go +++ b/main.go @@ -23,7 +23,7 @@ import ( "cliproxy/internal/tui" ) -const version = "0.1.2" +const version = "0.1.3" func main() { home := ca.Dir() @@ -31,7 +31,7 @@ func main() { var ( addr = flag.String("addr", "0.0.0.0:8080", "address the proxy listens on (0.0.0.0 exposes it to your LAN for devices)") port = flag.Int("port", 0, "port to listen on; overrides the port part of -addr") - tunnel = flag.Bool("tunnel", false, "do not intercept TLS; only tunnel CONNECT and show the endpoints") + tunnel = flag.Bool("tunnel", false, "forward HTTPS through CONNECT without TLS interception or a CA certificate; plain HTTP capture stays enabled") caDir = flag.String("ca-dir", home, "directory holding the CA certificate and key") rulesPath = flag.String("rules", filepath.Join(home, "rules.json"), "rule file (JSON), loaded at start and saved on every change") filtersPath = flag.String("filters", filepath.Join(home, "filters.json"), "file holding the saved display filters") @@ -61,9 +61,14 @@ func main() { fatal("%v", err) } - authority, err := ca.Load(*caDir) - if err != nil { - fatal("certificate authority: %v", err) + var authority *ca.CA + var trustMgr *trust.Manager + if !*tunnel || *installCert || *removeCert { + authority, err = ca.Load(*caDir) + if err != nil { + fatal("certificate authority: %v", err) + } + trustMgr = trust.New(authority.CertPath(), authority.CommonName()) } store := core.NewStore(*flowLimit) @@ -85,7 +90,6 @@ func main() { } // Certificate installation is a standalone action; do it and stop. - trustMgr := trust.New(authority.CertPath(), authority.CommonName()) if *installCert || *removeCert { if err := certAction(trustMgr, *installCert, *certSystem); err != nil { fatal("%v", err) @@ -149,6 +153,10 @@ func main() { interactive := !*headless && term.IsTerminal(os.Stdin) && term.IsTerminal(os.Stdout) if interactive { printBanner(px, authority, *caDir, *rulesPath, *filtersPath, sysCtrl) + var uiTrust tui.CertTrust + if trustMgr != nil { + uiTrust = trustMgr + } err := tui.Run(tui.Config{ Proxy: px, Store: store, @@ -158,7 +166,7 @@ func main() { Log: elog, Version: version, SystemProxy: sysCtrl, - Trust: trustMgr, + Trust: uiTrust, Filters: filterset, Stop: stop, }) @@ -278,8 +286,12 @@ func printBanner(px *proxy.Proxy, authority *ca.CA, caDir, rulesPath, filtersPat for _, ip := range proxy.LocalIPs() { fmt.Fprintf(&b, " ├ devices use %s\n", ip) } - fmt.Fprintf(&b, " ├ certificate %s\n", caDir) - fmt.Fprintf(&b, " ├ install from %s/cert\n", px.BaseURL()) + if authority != nil { + fmt.Fprintf(&b, " ├ certificate %s\n", caDir) + fmt.Fprintf(&b, " ├ install from %s/cert\n", px.BaseURL()) + } else { + fmt.Fprintln(&b, " ├ TLS pass-through; no CA certificate required") + } fmt.Fprintf(&b, " ├ rules %s\n", rulesPath) fmt.Fprintf(&b, " ├ filters %s\n", filtersPath) if sysCtrl.Active() { @@ -297,7 +309,11 @@ func runHeadless(px *proxy.Proxy, store *core.Store, opts *core.Options, breaker signal.Notify(sig, os.Interrupt, syscall.SIGTERM) fmt.Printf("cli-proxy %s headless — listening on %s (%s)\n", version, px.DisplayAddr(), px.Mode()) - fmt.Printf("install the CA from %s/cert\n", px.BaseURL()) + if px.CA() != nil { + fmt.Printf("install the CA from %s/cert\n", px.BaseURL()) + } else { + fmt.Println("TLS pass-through; no CA certificate required") + } fmt.Printf("%-6s %-8s %-7s %-28s %-9s %s\n", "id", "time", "method", "host", "status", "url") seen := map[int64]bool{} diff --git a/main_test.go b/main_test.go index b682af7..dfaeae7 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,64 @@ package main -import "testing" +import ( + "bufio" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestTunnelStartupWithoutCA(t *testing.T) { + if os.Getenv("CLI_PROXY_TEST_TUNNEL") == "1" { + os.Args = []string{"cli-proxy", "-tunnel", "-headless", "-addr", "127.0.0.1:0"} + main() + os.Exit(0) + } + for _, invalidDir := range []bool{false, true} { + name := "missing-directory" + if invalidDir { + name = "invalid-directory" + } + t.Run(name, func(t *testing.T) { + home := filepath.Join(t.TempDir(), "proxy-home") + if invalidDir { + if err := os.WriteFile(home, []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestTunnelStartupWithoutCA$") + cmd.Env = append(os.Environ(), "CLI_PROXY_TEST_TUNNEL=1", "CLI_PROXY_HOME="+home) + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }() + scanner := bufio.NewScanner(out) + if !scanner.Scan() || !strings.Contains(scanner.Text(), "(tunnel)") { + t.Fatalf("proxy did not start in tunnel mode: %q, %v", scanner.Text(), scanner.Err()) + } + if !scanner.Scan() || !strings.Contains(scanner.Text(), "no CA certificate required") { + t.Fatalf("unexpected startup instructions: %q", scanner.Text()) + } + if !invalidDir { + if _, err := os.Stat(home); !os.IsNotExist(err) { + t.Fatalf("tunnel startup created CA directory: %v", err) + } + } + }) + } +} func TestResolveAddr(t *testing.T) { cases := []struct {