diff --git a/DECISIONS.md b/DECISIONS.md index 15f0fa6..cf6f57e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -90,3 +90,4 @@ - 2026-03-01 m+git@andri.dk — Added landing page in `web/` deployed via GitHub Pages (artifact-based workflow). Single self-contained `index.html` with all CSS inlined, no build step, no external JS dependencies. JetBrains Mono font, CRT/terminal aesthetic with neon green/cyan/magenta/yellow accents on dark `#08080c` background. Deployed to `andrioid.github.io/ublproxy` via `actions/deploy-pages@v4` on pushes to `main` that touch `web/**`. - 2026-03-01 m+git@andri.dk — Added portal UI screenshots to landing page. 4 PNGs (setup wizard, dashboard, activity feed, subscriptions) captured via Playwright with mocked API responses. Displayed in 2x2 grid "See it in action" section between Features and Architecture. Screenshots are served as static assets from `web/`. - 2026-03-02 m+git@andri.dk — Portal HTTPS cert now cached with expiration, matching leaf cert behavior. Previously, `startPortalHTTPS` generated the portal cert once at startup and baked it into `tls.Config.Certificates` — after 24h of uptime it expired, causing TLS errors for the portal, PAC file, and all proxy traffic routed through the HTTPS port. Fix: `Cache.GetPortalCert()` caches the portal cert with a 24h `expiresAt` and regenerates on demand. `startPortalHTTPS` uses `tls.Config.GetCertificate` callback instead of a static cert. Transparent mode was unaffected (already called `PortalCert()` per-connection). +- 2026-03-02 m+git@andri.dk — TLS handshake failure circuit breaker for automatic cert-pin detection. When a host accumulates 3 TLS handshake failures within 10 minutes, the proxy auto-switches it to passthrough (no MITM) for 1 hour. After the TTL expires, a single failure re-trips the breaker immediately (`prevTripped` flag) to avoid repeated breakage. `RecordSuccess` clears all state when a MITM handshake succeeds, proving the host is not pinned. Portal host and IPs are excluded — they can never be auto-passthrough'd. Events are logged at warn level and recorded in the activity feed as `auto-passthrough`. Works in both explicit and transparent proxy modes. No CLI flags; all thresholds hardcoded. In-memory only; resets on restart. diff --git a/activity.go b/activity.go index c5931f0..aafda2f 100644 --- a/activity.go +++ b/activity.go @@ -7,10 +7,11 @@ import ( // Activity types for the proxy event log. const ( - ActivityBlocked = "blocked" - ActivityAllowed = "allowed" - ActivityPassthrough = "passthrough" - ActivityElementHidden = "element-hidden" + ActivityBlocked = "blocked" + ActivityAllowed = "allowed" + ActivityPassthrough = "passthrough" + ActivityAutoPassthrough = "auto-passthrough" + ActivityElementHidden = "element-hidden" ) // ActivityEntry represents a single proxy event. diff --git a/connect.go b/connect.go index 5548a5f..20d1701 100644 --- a/connect.go +++ b/connect.go @@ -35,6 +35,15 @@ func (p *proxyHandler) handleConnect(w http.ResponseWriter, r *http.Request) { return } + // Circuit breaker: skip MITM for hosts with repeated handshake + // failures (likely cert-pinned). + if p.handshakeTracker != nil && p.handshakeTracker.IsTripped(host) { + p.logActivity(ActivityAutoPassthrough, host, "", "auto-passthrough", clientIP, credID) + logAutoPassthrough(host, clientIP, credID) + p.tunnelPassthrough(w, r, host, port, clientIP, credID) + return + } + hijacker, ok := w.(http.Hijacker) if !ok { http.Error(w, "hijacking not supported", http.StatusInternalServerError) @@ -65,10 +74,17 @@ func (p *proxyHandler) handleConnect(w http.ResponseWriter, r *http.Request) { }) if err := tlsClientConn.Handshake(); err != nil { logError("connect/client-tls", err, clientIP, credID) + if p.handshakeTracker != nil { + p.handshakeTracker.RecordFailure(host) + } return } defer tlsClientConn.Close() + if p.handshakeTracker != nil { + p.handshakeTracker.RecordSuccess(host) + } + // Clear the deadline after successful handshake clientConn.SetDeadline(time.Time{}) diff --git a/handshake_tracker.go b/handshake_tracker.go new file mode 100644 index 0000000..3c6d380 --- /dev/null +++ b/handshake_tracker.go @@ -0,0 +1,139 @@ +package main + +import ( + "net" + "sync" + "time" +) + +// Circuit breaker thresholds for auto-passthrough of cert-pinned hosts. +// When a host accumulates enough TLS handshake failures within a window, +// subsequent connections skip MITM and are tunneled directly to upstream. +const ( + handshakeFailureThreshold = 3 + handshakeFailureWindow = 10 * time.Minute + handshakeTrippedTTL = 1 * time.Hour +) + +type failureRecord struct { + count int + firstSeen time.Time + trippedAt time.Time // zero = not tripped + prevTripped bool // re-trip on single failure after TTL expires +} + +// handshakeTracker implements a circuit breaker for TLS handshake failures. +// Hosts that repeatedly reject the proxy's MITM certificate (e.g. due to +// certificate pinning) are automatically switched to passthrough mode. +type handshakeTracker struct { + mu sync.Mutex + failures map[string]*failureRecord + excluded map[string]bool +} + +func newHandshakeTracker(portalHost string, portalIPs []net.IP) *handshakeTracker { + excluded := map[string]bool{ + portalHost: true, + "localhost": true, + "127.0.0.1": true, + } + for _, ip := range portalIPs { + if ip != nil { + excluded[ip.String()] = true + } + } + return &handshakeTracker{ + failures: make(map[string]*failureRecord), + excluded: excluded, + } +} + +// RecordFailure records a TLS handshake failure for a host. Returns true +// if the circuit breaker tripped (threshold reached). Excluded hosts +// (portal) are ignored. +func (t *handshakeTracker) RecordFailure(host string) bool { + t.mu.Lock() + defer t.mu.Unlock() + + if t.excluded[host] { + return false + } + + now := time.Now() + rec, ok := t.failures[host] + if !ok { + t.failures[host] = &failureRecord{ + count: 1, + firstSeen: now, + } + return false + } + + // Already tripped -- nothing to do + if !rec.trippedAt.IsZero() && now.Before(rec.trippedAt.Add(handshakeTrippedTTL)) { + return false + } + + // Previously tripped host: re-trip on a single failure + if rec.prevTripped { + rec.count = handshakeFailureThreshold + rec.trippedAt = now + rec.prevTripped = false + return true + } + + // Window expired: reset counter + if now.After(rec.firstSeen.Add(handshakeFailureWindow)) { + rec.count = 1 + rec.firstSeen = now + rec.trippedAt = time.Time{} + return false + } + + rec.count++ + if rec.count >= handshakeFailureThreshold { + rec.trippedAt = now + return true + } + + return false +} + +// IsTripped returns whether a host is in auto-passthrough due to repeated +// TLS handshake failures. Excluded hosts (portal) always return false. +func (t *handshakeTracker) IsTripped(host string) bool { + t.mu.Lock() + defer t.mu.Unlock() + + if t.excluded[host] { + return false + } + + rec, ok := t.failures[host] + if !ok { + return false + } + + if rec.trippedAt.IsZero() { + return false + } + + // TTL expired: enter half-open state + if time.Now().After(rec.trippedAt.Add(handshakeTrippedTTL)) { + rec.prevTripped = true + rec.trippedAt = time.Time{} + rec.count = 0 + rec.firstSeen = time.Time{} + return false + } + + return true +} + +// RecordSuccess clears all failure state for a host. A successful MITM +// handshake proves the host is not cert-pinned. +func (t *handshakeTracker) RecordSuccess(host string) { + t.mu.Lock() + defer t.mu.Unlock() + delete(t.failures, host) +} diff --git a/handshake_tracker_test.go b/handshake_tracker_test.go new file mode 100644 index 0000000..9b4f34e --- /dev/null +++ b/handshake_tracker_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "net" + "testing" + "time" +) + +func TestBelowThresholdDoesNotTrip(t *testing.T) { + tr := newHandshakeTracker("portal.local", nil) + + tr.RecordFailure("pinned.example.com") + tr.RecordFailure("pinned.example.com") + + if tr.IsTripped("pinned.example.com") { + t.Error("expected not tripped after only 2 failures") + } +} + +func TestThresholdReachedTrips(t *testing.T) { + tr := newHandshakeTracker("portal.local", nil) + + tr.RecordFailure("pinned.example.com") + tr.RecordFailure("pinned.example.com") + tripped := tr.RecordFailure("pinned.example.com") + + if !tripped { + t.Error("expected RecordFailure to return true when breaker trips") + } + if !tr.IsTripped("pinned.example.com") { + t.Error("expected host to be tripped after 3 failures") + } +} + +func TestWindowExpiryResetsCounter(t *testing.T) { + tr := newHandshakeTracker("portal.local", nil) + + tr.RecordFailure("pinned.example.com") + tr.RecordFailure("pinned.example.com") + + // Simulate window expiry by backdating firstSeen + tr.mu.Lock() + tr.failures["pinned.example.com"].firstSeen = time.Now().Add(-handshakeFailureWindow - time.Second) + tr.mu.Unlock() + + // This failure should start a new window, not accumulate + tr.RecordFailure("pinned.example.com") + + if tr.IsTripped("pinned.example.com") { + t.Error("expected not tripped after window expired and only 1 new failure") + } +} + +func TestTrippedTTLExpires(t *testing.T) { + tr := newHandshakeTracker("portal.local", nil) + + // Trip the breaker + tr.RecordFailure("pinned.example.com") + tr.RecordFailure("pinned.example.com") + tr.RecordFailure("pinned.example.com") + + if !tr.IsTripped("pinned.example.com") { + t.Fatal("expected tripped") + } + + // Simulate TTL expiry + tr.mu.Lock() + tr.failures["pinned.example.com"].trippedAt = time.Now().Add(-handshakeTrippedTTL - time.Second) + tr.mu.Unlock() + + if tr.IsTripped("pinned.example.com") { + t.Error("expected not tripped after TTL expired") + } +} + +func TestSuccessClearsAllState(t *testing.T) { + tr := newHandshakeTracker("portal.local", nil) + + tr.RecordFailure("pinned.example.com") + tr.RecordFailure("pinned.example.com") + + tr.RecordSuccess("pinned.example.com") + + // A single new failure should not trip (counter was reset) + tr.RecordFailure("pinned.example.com") + if tr.IsTripped("pinned.example.com") { + t.Error("expected not tripped after success cleared state") + } +} + +func TestDifferentHostsIndependent(t *testing.T) { + tr := newHandshakeTracker("portal.local", nil) + + tr.RecordFailure("host-a.example.com") + tr.RecordFailure("host-a.example.com") + tr.RecordFailure("host-a.example.com") + + if tr.IsTripped("host-b.example.com") { + t.Error("failures on host-a should not trip host-b") + } + if !tr.IsTripped("host-a.example.com") { + t.Error("host-a should be tripped") + } +} + +func TestPreviouslyTrippedHostRetripsOnSingleFailure(t *testing.T) { + tr := newHandshakeTracker("portal.local", nil) + + // Trip the breaker + tr.RecordFailure("pinned.example.com") + tr.RecordFailure("pinned.example.com") + tr.RecordFailure("pinned.example.com") + + // Expire the TTL (this should set prevTripped=true) + tr.mu.Lock() + tr.failures["pinned.example.com"].trippedAt = time.Now().Add(-handshakeTrippedTTL - time.Second) + tr.mu.Unlock() + + // Confirm it's no longer tripped + if tr.IsTripped("pinned.example.com") { + t.Fatal("expected not tripped after TTL expired") + } + + // A single failure should re-trip immediately + tripped := tr.RecordFailure("pinned.example.com") + if !tripped { + t.Error("expected RecordFailure to return true for previously tripped host") + } + if !tr.IsTripped("pinned.example.com") { + t.Error("expected re-tripped after single failure on previously tripped host") + } +} + +func TestExcludedHostNeverTrips(t *testing.T) { + tr := newHandshakeTracker("portal.local", nil) + + for range 10 { + tr.RecordFailure("portal.local") + } + + if tr.IsTripped("portal.local") { + t.Error("portal host should never be tripped") + } +} + +func TestExcludedIPNeverTrips(t *testing.T) { + lanIP := net.ParseIP("192.168.1.100") + tr := newHandshakeTracker("portal.local", []net.IP{lanIP}) + + for range 10 { + tr.RecordFailure("192.168.1.100") + } + + if tr.IsTripped("192.168.1.100") { + t.Error("portal IP should never be tripped") + } +} diff --git a/log.go b/log.go index b36ac95..93fa608 100644 --- a/log.go +++ b/log.go @@ -108,3 +108,13 @@ func logPassthrough(host, clientIP, credentialID string) { "user", shortUserID(credentialID), ) } + +// logAutoPassthrough logs when the circuit breaker bypasses MITM for a +// host with repeated TLS handshake failures (likely cert-pinned). +func logAutoPassthrough(host, clientIP, credentialID string) { + slog.Warn("auto-passthrough", + "host", host, + "ip", clientIP, + "user", shortUserID(credentialID), + ) +} diff --git a/main.go b/main.go index 46678f8..a7e26b9 100644 --- a/main.go +++ b/main.go @@ -150,6 +150,8 @@ func run(_ context.Context, cmd *cli.Command) error { extraIPs = append(extraIPs, net.ParseIP(lanIP)) } + handler.handshakeTracker = newHandshakeTracker(hostname, extraIPs) + // For the HTTP origin, prefer the LAN IP over "localhost" since // devices need a routable address to reach the proxy. httpHost := hostname diff --git a/proxy.go b/proxy.go index fc37edc..fbbfadf 100644 --- a/proxy.go +++ b/proxy.go @@ -30,6 +30,10 @@ type proxyHandler struct { // activityLog records recent proxy events for the activity feed. activityLog *ActivityLog + // handshakeTracker detects cert-pinned hosts via repeated TLS + // handshake failures and auto-switches them to passthrough. + handshakeTracker *handshakeTracker + // baselineRules are the always-active rules loaded from --blocklist // sources and --default-subscription lists. These apply to all traffic // regardless of user. diff --git a/transparent.go b/transparent.go index 9854446..8cce283 100644 --- a/transparent.go +++ b/transparent.go @@ -478,6 +478,16 @@ func handleTransparentTLSConn(conn net.Conn, proxy *proxyHandler, certs *ca.Cach return } + // Circuit breaker: skip MITM for hosts with repeated handshake + // failures (likely cert-pinned). The buffered reader still has + // the peeked ClientHello, so passthrough can replay it upstream. + if proxy.handshakeTracker != nil && proxy.handshakeTracker.IsTripped(sni) { + proxy.logActivity(ActivityAutoPassthrough, sni, "", "auto-passthrough", clientIP, credID) + logAutoPassthrough(sni, clientIP, credID) + handleTransparentPassthrough(conn, br, sni, clientIP, credID) + return + } + // MITM: generate cert, complete TLS handshake, proxy requests handleTransparentMITM(conn, br, proxy, certs, sni, clientIP, credID, trustTracker) } @@ -570,10 +580,17 @@ func handleTransparentMITM(conn net.Conn, br *bufio.Reader, proxy *proxyHandler, if err := tlsConn.Handshake(); err != nil { // Client doesn't trust our CA — this is expected for unconfigured clients logError("transparent-tls/client-tls", err, clientIP, credID) + if proxy.handshakeTracker != nil { + proxy.handshakeTracker.RecordFailure(sni) + } return } defer tlsConn.Close() + if proxy.handshakeTracker != nil { + proxy.handshakeTracker.RecordSuccess(sni) + } + // Successful handshake means client trusts our CA trustTracker.markTrusted(clientIP)