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 {