diff --git a/main.go b/main.go index 2a6a94e..ba9b540 100644 --- a/main.go +++ b/main.go @@ -30,8 +30,8 @@ func main() { defaultDataDir := filepath.Join(home, ".ublproxy") addr := flag.String("addr", "0.0.0.0", "address to listen on (0.0.0.0 for all interfaces)") - port := flag.Int("port", 8080, "port to listen on") - portalPort := flag.Int("portal-port", 8443, "HTTPS portal port for WebAuthn and rule management") + httpPort := flag.Int("http-port", 8080, "HTTP port for setup page and CA certificate download") + httpsPort := flag.Int("https-port", 8443, "HTTPS port for proxy, portal, and API") hostname := flag.String("hostname", "localhost", "portal hostname for WebAuthn and TLS cert (must be a domain, not an IP)") caDir := flag.String("ca-dir", defaultDataDir, "directory for CA certificate and key") dbPath := flag.String("db", filepath.Join(defaultDataDir, "ublproxy.db"), "path to SQLite database") @@ -82,7 +82,7 @@ func main() { // Configure WebAuthn with the portal hostname. WebAuthn requires a // domain name as RP ID — IP addresses are not allowed by the spec. - portalOrigin := fmt.Sprintf("https://%s:%d", *hostname, *portalPort) + portalOrigin := fmt.Sprintf("https://%s:%d", *hostname, *httpsPort) webauthnCfg := webauthn.Config{ RPID: *hostname, RPName: "ublproxy", @@ -103,16 +103,20 @@ func main() { extraIPs = append(extraIPs, net.ParseIP(lanIP)) } - // Start HTTPS portal in a goroutine - portalAddr := fmt.Sprintf("%s:%d", *addr, *portalPort) + // Start HTTPS proxy+portal server in a goroutine. This handles + // proxy CONNECT/forward, the management portal, and the API. + httpsAddr := fmt.Sprintf("%s:%d", *addr, *httpsPort) portalH := &portalHandler{proxy: handler, api: api} - go startPortalHTTPS(portalAddr, *hostname, extraIPs, certs, portalH) + go startPortalHTTPS(httpsAddr, *hostname, extraIPs, certs, portalH) - listenAddr := fmt.Sprintf("%s:%d", *addr, *port) - fmt.Fprintf(os.Stderr, "ublproxy listening on http://%s\n", listenAddr) - fmt.Fprintf(os.Stderr, "ublproxy portal at %s\n", portalOrigin) + // HTTP server serves only the setup page and CA certificate download. + // No proxy, no API — those require TLS on the HTTPS port. + httpAddr := fmt.Sprintf("%s:%d", *addr, *httpPort) + setupH := &setupHandler{caCertPEM: caCertPEM, portalOrigin: portalOrigin} + fmt.Fprintf(os.Stderr, "ublproxy setup page on http://%s\n", httpAddr) + fmt.Fprintf(os.Stderr, "ublproxy proxy+portal on %s\n", portalOrigin) - if err := http.ListenAndServe(listenAddr, handler); err != nil { + if err := http.ListenAndServe(httpAddr, setupH); err != nil { fmt.Fprintf(os.Stderr, "server error: %v\n", err) os.Exit(1) } diff --git a/playwright-cli-proxy.json b/playwright-cli-proxy.json index 8b5bda0..b9bb031 100644 --- a/playwright-cli-proxy.json +++ b/playwright-cli-proxy.json @@ -3,7 +3,7 @@ "contextOptions": { "ignoreHTTPSErrors": true, "proxy": { - "server": "http://127.0.0.1:8080", + "server": "https://localhost:8443", "bypass": "localhost,127.0.0.1,<-loopback>" } } diff --git a/portal.go b/portal.go index c03c6da..58df6c1 100644 --- a/portal.go +++ b/portal.go @@ -2,36 +2,111 @@ package main import ( _ "embed" + "html/template" "net/http" + "net/url" ) //go:embed static/portal.html var portalHTML string +//go:embed static/setup.html +var setupHTML string + +var setupTmpl = template.Must(template.New("setup").Parse(setupHTML)) + +// setupHandler serves the HTTP-only setup page and CA certificate. +// No proxy, no API — those require TLS on the HTTPS port. +type setupHandler struct { + caCertPEM []byte + portalOrigin string +} + +func (s *setupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ca.crt" { + w.Header().Set("Content-Type", "application/x-pem-file") + w.Header().Set("Content-Disposition", `attachment; filename="ublproxy-ca.crt"`) + w.WriteHeader(http.StatusOK) + w.Write(s.caCertPEM) + return + } + if r.URL.Path == "/proxy.pac" { + s.handlePAC(w, r) + return + } + if r.URL.Path == "/" || r.URL.Path == "/setup" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + setupTmpl.Execute(w, struct{ PortalURL string }{s.portalOrigin}) + return + } + http.NotFound(w, r) +} + +// 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) { + parsed, err := url.Parse(s.portalOrigin) + if err != nil { + http.Error(w, "misconfigured portal origin", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/x-ns-proxy-autoconfig") + pacTmpl.Execute(w, struct{ ProxyHost string }{parsed.Host}) +} + +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"; + } + return "HTTPS {{.ProxyHost}}"; +} +`)) + +// handlePortal routes direct requests on the proxy listener (used by +// proxyHandler.ServeHTTP when r.URL.Host is empty). In production the +// proxy runs on the HTTPS port and direct requests go through +// portalHandler instead, but this path is still exercised by tests +// that create an httptest.NewServer with proxyHandler. func (p *proxyHandler) handlePortal(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/ca.crt" { p.handlePortalCACert(w, r) return } - if r.URL.Path == "/" { - p.handlePortalIndex(w, r) + if r.URL.Path == "/proxy.pac" { + p.handlePAC(w, r) return } - // Route API requests to the API handler (works on localhost where - // HTTPS isn't required for the rule management endpoints) - if p.api != nil && len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api" { - p.api.ServeHTTP(w, r) + if r.URL.Path == "/" || r.URL.Path == "/setup" { + p.handleSetup(w, r) return } http.NotFound(w, r) } +func (p *proxyHandler) handlePAC(w http.ResponseWriter, r *http.Request) { + parsed, err := url.Parse(p.portalOrigin) + if err != nil { + http.Error(w, "misconfigured portal origin", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/x-ns-proxy-autoconfig") + pacTmpl.Execute(w, struct{ ProxyHost string }{parsed.Host}) +} + func (p *proxyHandler) handlePortalIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) w.Write([]byte(portalHTML)) } +func (p *proxyHandler) handleSetup(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + setupTmpl.Execute(w, struct{ PortalURL string }{p.portalOrigin}) +} + func (p *proxyHandler) handlePortalCACert(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/x-pem-file") w.Header().Set("Content-Disposition", `attachment; filename="ublproxy-ca.crt"`) diff --git a/portal_https.go b/portal_https.go index 7d6b5f4..ad03db5 100644 --- a/portal_https.go +++ b/portal_https.go @@ -8,25 +8,38 @@ import ( "os" ) -// portalServer is the HTTPS server for the portal. It serves the dashboard, -// WebAuthn auth endpoints, and the rule management API. Separate from the -// HTTP proxy so that WebAuthn works (requires a secure context). -type portalServer struct { - handler *portalHandler - addr string -} - -// portalHandler routes requests on the HTTPS portal. +// portalHandler routes requests on the HTTPS port. It serves the proxy +// (CONNECT tunnels and HTTP forwarding), the management portal, and +// the API. All traffic on this listener is TLS-encrypted. type portalHandler struct { proxy *proxyHandler api *apiHandler } func (h *portalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Proxy: CONNECT tunnels (browser requests HTTPS via the proxy) + if r.Method == http.MethodConnect { + h.proxy.handleConnect(w, r) + return + } + // Proxy: HTTP forward (absolute-URI requests through the proxy) + if r.URL.Host != "" { + h.proxy.handleHTTP(w, r) + return + } + // Direct requests to this server (portal, API, setup, CA cert, PAC) if r.URL.Path == "/ca.crt" { h.proxy.handlePortalCACert(w, r) return } + if r.URL.Path == "/proxy.pac" { + h.proxy.handlePAC(w, r) + return + } + if r.URL.Path == "/setup" { + h.proxy.handleSetup(w, r) + return + } if r.URL.Path == "/" { h.proxy.handlePortalIndex(w, r) return @@ -38,12 +51,10 @@ func (h *portalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) } -// startPortalHTTPS starts the HTTPS portal server using a TLS certificate -// signed by the proxy's CA. The cert is generated for the listen address -// so clients that trust the CA can connect without warnings. +// startPortalHTTPS starts the HTTPS server that handles both proxy +// traffic and the management portal. HTTP/2 is disabled because +// CONNECT tunnels require Hijack which only works with HTTP/1.1. func startPortalHTTPS(listenAddr string, host string, extraIPs []net.IP, certs *certCache, handler *portalHandler) { - // Generate a TLS cert that covers the portal hostname, localhost, - // 127.0.0.1, and any additional IPs (e.g. the LAN IP). cert, err := certs.portalCert(host, extraIPs...) if err != nil { fmt.Fprintf(os.Stderr, "portal: failed to generate TLS cert: %v\n", err) @@ -58,13 +69,15 @@ func startPortalHTTPS(listenAddr string, host string, extraIPs []net.IP, certs * Addr: listenAddr, Handler: handler, TLSConfig: tlsConfig, + // Disable HTTP/2 — CONNECT proxy requires Hijack() which is + // only supported on HTTP/1.1 connections. + TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), } - fmt.Fprintf(os.Stderr, "ublproxy portal listening on https://%s\n", listenAddr) + fmt.Fprintf(os.Stderr, "ublproxy proxy+portal listening on https://%s\n", listenAddr) - // TLS certs are already in the config, so pass empty strings if err := server.ListenAndServeTLS("", ""); err != nil { - fmt.Fprintf(os.Stderr, "portal server error: %v\n", err) + fmt.Fprintf(os.Stderr, "server error: %v\n", err) os.Exit(1) } } diff --git a/proxy_test.go b/proxy_test.go index 3982462..b779c5f 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -35,6 +35,7 @@ type testEnv struct { httpURL string httpsURL string caPool *x509.CertPool + handler *proxyHandler } // startTestEnv spins up an upstream HTTP server, an upstream HTTPS server, and @@ -86,6 +87,7 @@ func startTestEnv(t *testing.T, upstreamHandler http.Handler, rules *blocklist.R httpURL: httpServer.URL, httpsURL: httpsServer.URL, caPool: caPool, + handler: handler, } } @@ -343,6 +345,40 @@ func TestPortalCACertDownload(t *testing.T) { } } +func TestProxyPAC(t *testing.T) { + env := startTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }), nil) + + // Set portalOrigin so the PAC template can extract host:port + env.handler.portalOrigin = "https://myhost:9443" + + resp, err := http.Get(env.proxyURL + "/proxy.pac") + if err != nil { + t.Fatalf("GET /proxy.pac: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + contentType := resp.Header.Get("Content-Type") + if contentType != "application/x-ns-proxy-autoconfig" { + t.Errorf("Content-Type = %q, want %q", contentType, "application/x-ns-proxy-autoconfig") + } + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + if !strings.Contains(bodyStr, "FindProxyForURL") { + t.Error("PAC body does not contain FindProxyForURL function") + } + if !strings.Contains(bodyStr, "HTTPS myhost:9443") { + t.Errorf("PAC body does not contain expected proxy directive, got:\n%s", bodyStr) + } +} + func TestHTTPSProxyPOST(t *testing.T) { var receivedBody string diff --git a/scripts/browser b/scripts/browser index 5e46cba..fbeda9c 100755 --- a/scripts/browser +++ b/scripts/browser @@ -2,8 +2,8 @@ #MISE description="Open a browser routed through the proxy" set -euo pipefail -if ! curl -s --max-time 2 --proxy http://127.0.0.1:8080 http://example.com -o /dev/null 2>/dev/null; then - echo "Error: proxy is not running on 127.0.0.1:8080" +if ! curl -s --max-time 2 --proxy https://localhost:8443 --proxy-insecure -k https://example.com -o /dev/null 2>/dev/null; then + echo "Error: proxy is not running on localhost:8443" echo "Start it first: mise run dev -- --blocklist examples/is.rules" exit 1 fi diff --git a/static/portal.html b/static/portal.html index 3dc3e32..a0f2067 100644 --- a/static/portal.html +++ b/static/portal.html @@ -40,7 +40,6 @@ font-size: 0.85em; font-family: inherit; } - .section { border-top: 1px solid #eee; padding-top: 1rem; margin-top: 1rem; } .card { background: #f8fafc; border: 1px solid #e2e8f0; @@ -82,17 +81,6 @@ .msg.info { display: block; background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; } .auth-status { font-size: 0.9rem; color: #666; } .auth-status .ok { color: #166534; font-weight: 500; } - .download { - display: inline-block; - background: #2563eb; - color: #fff; - text-decoration: none; - padding: 0.6rem 1.2rem; - border-radius: 6px; - font-weight: 500; - margin-bottom: 1.5rem; - } - .download:hover { background: #1d4ed8; } .hidden { display: none !important; } .rule-list, .sub-list { list-style: none; padding: 0; margin: 0.75rem 0 0; } .rule-item, .sub-item { @@ -180,6 +168,8 @@ } .card-header h2 { margin: 0; } .count { font-size: 0.8rem; color: #999; font-weight: normal; } + .ca-link { font-size: 0.85rem; color: #666; margin-bottom: 1.5rem; } + .ca-link a { color: #2563eb; }
@@ -187,6 +177,8 @@Ad-blocking proxy with interactive element picker.
+Install CA certificate — required for HTTPS interception
+To intercept HTTPS traffic, your browser or OS must trust the ublproxy CA certificate.
- Download CA Certificate - -sudo cp ublproxy-ca.crt /usr/local/share/ca-certificates/sudo update-ca-certificatesInstall the CA certificate to enable HTTPS interception.
+ +Your browser and OS must trust the ublproxy CA certificate before the proxy can intercept HTTPS traffic. Without it, every HTTPS request through the proxy will fail with a certificate error.
+ Download CA Certificate +Follow the instructions for your platform:
+ +sudo cp ublproxy-ca.crt /usr/local/share/ca-certificates/sudo update-ca-certificatesUse the automatic proxy configuration file (PAC) to route traffic through ublproxy:
+
+
+ Chromium-based browsers use the system proxy settings. Configure the PAC URL at the OS level, or launch with:
+
+ + The PAC file routes all external traffic through the ublproxy HTTPS proxy. + Local addresses (localhost, 127.0.0.1) bypass the proxy so this setup + page and the management portal remain directly accessible. +
+ + +