diff --git a/DECISIONS.md b/DECISIONS.md index 94bfad3..f0ea469 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -65,3 +65,5 @@ - 2026-02-28 m+git@andri.dk — Moved `pkg/` to `internal/`. This is an application, not a library — `internal/` enforces that `blocklist`, `store`, and `webauthn` packages can't be imported by external code. - 2026-02-28 m+git@andri.dk — Extracted `ca.go` + `cert.go` into `internal/ca/` and `mobileconfig.go` into `internal/mobileconfig/`. These had zero dependencies on any `main`-package type — genuinely independent domain concepts (CA management, cert caching, Apple profile generation). Decided against extracting API code (`api*.go`) into `internal/api/` — the API handlers are deeply interleaved with the proxy (sessions, activity log, rule reload callbacks) and would require plumbing-only packages to break import cycles. File-level separation (`api_*.go` prefix) is sufficient for a single-binary proxy. - 2026-02-28 m+git@andri.dk — GitHub Actions CI/CD. `ci.yml` runs `go test ./...` on pull requests. `release.yml` runs on every push to main: tests, cross-compiles binaries for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, creates a GitHub Release (prerelease for commits, full release for `v*` tags), and builds+pushes multi-arch Docker images to `ghcr.io`. Version embedded via `-ldflags "-X main.version=..."` — tag name for tagged builds, short SHA for trunk builds. `ublproxy --version` now works. +- 2026-02-28 m+git@andri.dk — PAC scripts bypass all private/reserved network ranges (RFC 1918 `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, link-local `169.254.0.0/16`) and `*.local` mDNS hostnames. No reason to proxy local traffic — blocklists target public ad/tracking domains. Uses `isInNet()` on IP-literal hosts (no `dnsResolve()` overhead) and `shExpMatch()` for `*.local`. Fixes portal inaccessible when `--hostname` is a `.local` domain or when accessing via LAN IP — the PAC was directing portal traffic back through the proxy, causing a self-CONNECT loop. +- 2026-02-28 m+git@andri.dk — Unified desktop and mobile PAC templates into a single `pacTmpl`. The only difference is the proxy directive (`HTTPS` vs `PROXY`), now parameterized via `pacData.ProxyDirective`. Eliminates duplicated bypass logic that had to be maintained in two places. diff --git a/portal.go b/portal.go index 777f10c..811878e 100644 --- a/portal.go +++ b/portal.go @@ -118,6 +118,11 @@ func (s *setupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) } +type pacData struct { + ProxyDirective string // "HTTPS" for desktop, "PROXY" for mobile + ProxyHost string // e.g. "myhost:9443" +} + // handlePAC serves a Proxy Auto-Configuration file that directs browsers // to use the ublproxy HTTPS proxy for all non-local traffic. func (s *setupHandler) handlePAC(w http.ResponseWriter, r *http.Request) { @@ -127,7 +132,7 @@ func (s *setupHandler) handlePAC(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "application/x-ns-proxy-autoconfig") - pacTmpl.Execute(w, struct{ ProxyHost string }{parsed.Host}) + pacTmpl.Execute(w, pacData{ProxyDirective: "HTTPS", ProxyHost: parsed.Host}) } type setupData struct { @@ -161,31 +166,20 @@ func handleSetupVerify(w http.ResponseWriter, r *http.Request, httpOrigin string }) } +// pacTmpl is the single PAC template used by both desktop and mobile. +// Desktop uses ProxyDirective "HTTPS host:port", mobile uses "PROXY host:port" +// because iOS/Android do not support the HTTPS PAC proxy type. var pacTmpl = template.Must(template.New("pac").Parse(`function FindProxyForURL(url, host) { if (isPlainHostName(host) || host === "localhost" || host === "127.0.0.1" || host === "::1") { return "DIRECT"; } - // Captive portal / connectivity checks must bypass the proxy - // so the OS can verify internet access after wifi connects. - if (host === "captive.apple.com" || - host === "connectivitycheck.gstatic.com" || - host === "connectivitycheck.android.com" || - host === "clients3.google.com" || - host === "www.msftconnecttest.com" || - host === "dns.msftncsi.com" || - host === "detectportal.firefox.com") { - return "DIRECT"; - } - return "HTTPS {{.ProxyHost}}"; -} -`)) - -// mobilePacTmpl returns PROXY (plain HTTP) instead of HTTPS. iOS and Android -// do not support the HTTPS PAC proxy type, so mobile devices use this file. -var mobilePacTmpl = template.Must(template.New("mobilepac").Parse(`function FindProxyForURL(url, host) { - if (isPlainHostName(host) || - host === "localhost" || host === "127.0.0.1" || host === "::1") { + // Private/reserved networks — no reason to proxy local traffic. + if (isInNet(host, "10.0.0.0", "255.0.0.0") || + isInNet(host, "172.16.0.0", "255.240.0.0") || + isInNet(host, "192.168.0.0", "255.255.0.0") || + isInNet(host, "169.254.0.0", "255.255.0.0") || + shExpMatch(host, "*.local")) { return "DIRECT"; } // Captive portal / connectivity checks must bypass the proxy @@ -199,7 +193,7 @@ var mobilePacTmpl = template.Must(template.New("mobilepac").Parse(`function Find host === "detectportal.firefox.com") { return "DIRECT"; } - return "PROXY {{.ProxyHost}}"; + return "{{.ProxyDirective}} {{.ProxyHost}}"; } `)) @@ -210,7 +204,7 @@ func (s *setupHandler) handleMobilePAC(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "application/x-ns-proxy-autoconfig") - mobilePacTmpl.Execute(w, struct{ ProxyHost string }{parsed.Host}) + pacTmpl.Execute(w, pacData{ProxyDirective: "PROXY", ProxyHost: parsed.Host}) } func (s *setupHandler) handleQR(w http.ResponseWriter, r *http.Request) { @@ -265,7 +259,7 @@ func (p *proxyHandler) handlePAC(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "application/x-ns-proxy-autoconfig") - pacTmpl.Execute(w, struct{ ProxyHost string }{parsed.Host}) + pacTmpl.Execute(w, pacData{ProxyDirective: "HTTPS", ProxyHost: parsed.Host}) } func (p *proxyHandler) handleQR(w http.ResponseWriter, r *http.Request) { @@ -287,7 +281,7 @@ func (p *proxyHandler) handleMobilePAC(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "application/x-ns-proxy-autoconfig") - mobilePacTmpl.Execute(w, struct{ ProxyHost string }{parsed.Host}) + pacTmpl.Execute(w, pacData{ProxyDirective: "PROXY", ProxyHost: parsed.Host}) } func serveHTML(w http.ResponseWriter, content string) { diff --git a/proxy_test.go b/proxy_test.go index c760026..5969a24 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -416,6 +416,55 @@ func TestProxyPAC(t *testing.T) { } } +func TestPACBypassesPrivateNetworks(t *testing.T) { + env := startTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }), nil) + + env.handler.portalOrigin = "https://myhost.local:9443" + env.handler.httpOrigin = "http://192.168.1.100:8080" + + // Desktop PAC should bypass private networks + resp, err := http.Get(env.proxyURL + "/proxy.pac") + if err != nil { + t.Fatalf("GET /proxy.pac: %v", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + pac := string(body) + + privateRanges := []struct { + pattern string + desc string + }{ + {`isInNet(host, "10.0.0.0", "255.0.0.0")`, "10.0.0.0/8"}, + {`isInNet(host, "172.16.0.0", "255.240.0.0")`, "172.16.0.0/12"}, + {`isInNet(host, "192.168.0.0", "255.255.0.0")`, "192.168.0.0/16"}, + {`isInNet(host, "169.254.0.0", "255.255.0.0")`, "169.254.0.0/16"}, + {`*.local`, ".local mDNS"}, + } + for _, r := range privateRanges { + if !strings.Contains(pac, r.pattern) { + t.Errorf("desktop PAC missing bypass for %s (%s)", r.desc, r.pattern) + } + } + + // Mobile PAC should also bypass private networks + resp, err = http.Get(env.proxyURL + "/mobile.pac") + if err != nil { + t.Fatalf("GET /mobile.pac: %v", err) + } + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + mobilePac := string(body) + + for _, r := range privateRanges { + if !strings.Contains(mobilePac, r.pattern) { + t.Errorf("mobile PAC missing bypass for %s (%s)", r.desc, r.pattern) + } + } +} + func TestBlocksHTTPByHostname(t *testing.T) { var upstreamHit atomic.Bool