2 Commits

Author SHA1 Message Date
calic
5abdee06ff Add diagnostic logging to Chrome fallback
All checks were successful
Release / build (push) Successful in 1m32s
Log navigation, polling state, and response snippets so we can
diagnose whether the fallback fails due to Cloudflare challenge,
login redirect, profile lock, or other issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 00:34:41 +01:00
calic
2cb89d3c54 Fix Cloudflare headless detection: use non-headless with hidden window
All checks were successful
Release / build (push) Successful in 1m37s
Cloudflare detects headless Chrome and loops the JS challenge forever.
Switch to non-headless mode with an off-screen window. Also save
Cloudflare cookies (cf_clearance, __cf_bm) after Chrome fallback so
subsequent plain HTTP requests can reuse them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 00:26:34 +01:00
2 changed files with 123 additions and 35 deletions

View File

@@ -3,19 +3,22 @@ package browser
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"git.davoryn.de/calic/claude-statusline/internal/config" "git.davoryn.de/calic/claude-statusline/internal/config"
) )
// FetchViaChrome navigates to a URL using headless Chrome with the persistent // FetchViaChrome navigates to a URL using Chrome with the persistent browser
// browser profile (which has Cloudflare clearance cookies) and returns the // profile (which has Cloudflare clearance cookies) and returns the response
// response body. This bypasses Cloudflare JS challenges because Chrome runs // body. Uses non-headless mode with a minimized/hidden window to avoid
// real JavaScript. Falls back to non-headless if headless fails. // Cloudflare's headless detection, which causes infinite challenge loops.
func FetchViaChrome(url string) ([]byte, error) { func FetchViaChrome(url string) ([]byte, error) {
profileDir := filepath.Join(config.ConfigDir(), "browser-profile") profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
if err := os.MkdirAll(profileDir, 0o755); err != nil { if err := os.MkdirAll(profileDir, 0o755); err != nil {
@@ -27,8 +30,15 @@ func FetchViaChrome(url string) ([]byte, error) {
execPath := findBrowserExec() execPath := findBrowserExec()
// Use non-headless mode: Cloudflare detects headless Chrome and loops
// the JS challenge forever. A real (but hidden) browser window passes.
opts := append(chromedp.DefaultExecAllocatorOptions[:], opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true), chromedp.Flag("headless", false),
chromedp.Flag("window-position", "-32000,-32000"), // off-screen
chromedp.Flag("window-size", "1,1"),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("no-first-run", true),
chromedp.Flag("disable-extensions", true),
chromedp.UserDataDir(profileDir), chromedp.UserDataDir(profileDir),
) )
if execPath != "" { if execPath != "" {
@@ -41,36 +51,83 @@ func FetchViaChrome(url string) ([]byte, error) {
ctx, cancel := chromedp.NewContext(allocCtx) ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel() defer cancel()
// Set a total timeout for the operation // Total timeout for the operation
ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second) ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second)
defer timeoutCancel() defer timeoutCancel()
var body string // Navigate and wait for Cloudflare challenge to resolve.
err := chromedp.Run(ctx, log.Printf("chrome-fetch: navigating to %s (profile: %s)", url, profileDir)
chromedp.Navigate(url), if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil {
// Wait for the body to have content (Cloudflare challenge resolves via JS) log.Printf("chrome-fetch: navigate failed: %v", err)
chromedp.WaitReady("body"), return nil, fmt.Errorf("chromedp navigate: %w", err)
// Chrome renders JSON API responses inside a <pre> tag }
chromedp.Text("pre", &body, chromedp.ByQuery, chromedp.NodeVisible), log.Printf("chrome-fetch: navigation complete, polling for JSON...")
)
if err != nil { // Poll for JSON response — Cloudflare challenge takes a few seconds to clear
// Fallback: try extracting from body directly (some responses may not use <pre>) ticker := time.NewTicker(1 * time.Second)
var bodyFallback string defer ticker.Stop()
errFb := chromedp.Run(ctx,
chromedp.Text("body", &bodyFallback, chromedp.ByQuery), for {
) select {
if errFb == nil && bodyFallback != "" { case <-ctx.Done():
return []byte(bodyFallback), nil return nil, fmt.Errorf("chromedp fetch timed out waiting for JSON response")
case <-ticker.C:
var body string
// Try <pre> first (Chrome wraps JSON in <pre> tags)
err := chromedp.Run(ctx,
chromedp.Text("pre", &body, chromedp.ByQuery),
)
if err != nil || body == "" {
// Fallback: try body directly
_ = chromedp.Run(ctx,
chromedp.Text("body", &body, chromedp.ByQuery),
)
}
body = strings.TrimSpace(body)
if body == "" {
log.Printf("chrome-fetch: page body empty, waiting...")
continue
}
// Check if we got actual JSON (starts with [ or {), not a challenge page
if body[0] == '[' || body[0] == '{' {
log.Printf("chrome-fetch: got JSON response (%d bytes)", len(body))
// Also extract any fresh cookies for future plain HTTP attempts
_ = extractAndSaveCookies(ctx)
cancel() // graceful close, flushes cookies to profile
return []byte(body), nil
}
// Log a snippet of what we got (challenge page, login redirect, etc.)
snippet := body
if len(snippet) > 200 {
snippet = snippet[:200]
}
log.Printf("chrome-fetch: non-JSON body (%d bytes): %s", len(body), snippet)
} }
return nil, fmt.Errorf("chromedp fetch: %w", err)
} }
}
if body == "" {
return nil, fmt.Errorf("chromedp fetch: empty response body") // extractAndSaveCookies saves cf_clearance and other Cloudflare cookies
} // alongside the session key, so plain HTTP requests can try them next time.
func extractAndSaveCookies(ctx context.Context) error {
// Gracefully close to flush cookies (including refreshed cf_clearance) cookies, err := network.GetCookies().Do(ctx)
cancel() if err != nil {
return err
return []byte(body), nil }
var parts []string
for _, c := range cookies {
if c.Domain == ".claude.ai" || c.Domain == "claude.ai" {
if c.Name == "cf_clearance" || c.Name == "__cf_bm" || c.Name == "_cfuvid" {
parts = append(parts, c.Name+"="+c.Value)
}
}
}
if len(parts) == 0 {
return nil
}
// Write Cloudflare cookies to a file the fetcher can read
cfPath := filepath.Join(config.ConfigDir(), "cf-cookies")
return os.WriteFile(cfPath, []byte(strings.Join(parts, "\n")+"\n"), 0o600)
} }

View File

@@ -7,6 +7,9 @@ import (
"log" "log"
"math" "math"
"net/http" "net/http"
"os"
"path/filepath"
"strings"
"sync" "sync"
"time" "time"
@@ -34,13 +37,21 @@ type ParsedUsage struct {
type UpdateCallback func(ParsedUsage) type UpdateCallback func(ParsedUsage)
// doRequest performs an authenticated HTTP GET to the Claude API. // doRequest performs an authenticated HTTP GET to the Claude API.
// Includes any saved Cloudflare cookies from previous Chrome fallbacks.
func doRequest(url, sessionKey string) ([]byte, int, error) { func doRequest(url, sessionKey string) ([]byte, int, error) {
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
req.Header.Set("Cookie", "sessionKey="+sessionKey)
cookie := "sessionKey=" + sessionKey
// Append Cloudflare cookies if available (saved by Chrome fallback)
if cfCookies := loadCFCookies(); cfCookies != "" {
cookie += "; " + cfCookies
}
req.Header.Set("Cookie", cookie)
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Referer", "https://claude.ai/") req.Header.Set("Referer", "https://claude.ai/")
@@ -58,6 +69,24 @@ func doRequest(url, sessionKey string) ([]byte, int, error) {
return body, resp.StatusCode, nil return body, resp.StatusCode, nil
} }
// loadCFCookies reads saved Cloudflare cookies from the cf-cookies file.
func loadCFCookies() string {
data, err := os.ReadFile(filepath.Join(config.ConfigDir(), "cf-cookies"))
if err != nil {
return ""
}
// File has one cookie per line (name=value), join with "; "
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
var valid []string
for _, l := range lines {
l = strings.TrimSpace(l)
if l != "" {
valid = append(valid, l)
}
}
return strings.Join(valid, "; ")
}
// fetchWithFallback tries a plain HTTP request first, then falls back to // fetchWithFallback tries a plain HTTP request first, then falls back to
// headless Chrome (which can solve Cloudflare JS challenges) on 403. // headless Chrome (which can solve Cloudflare JS challenges) on 403.
func fetchWithFallback(url, sessionKey string) ([]byte, error) { func fetchWithFallback(url, sessionKey string) ([]byte, error) {
@@ -73,11 +102,13 @@ func fetchWithFallback(url, sessionKey string) ([]byte, error) {
} }
if status == 403 { if status == 403 {
// Likely a Cloudflare JS challenge — fall back to headless Chrome // Likely a Cloudflare JS challenge — fall back to headless Chrome
log.Printf("HTTP 403 for %s, falling back to headless Chrome", url) log.Printf("HTTP 403 for %s, falling back to Chrome", url)
chromeBody, chromeErr := browser.FetchViaChrome(url) chromeBody, chromeErr := browser.FetchViaChrome(url)
if chromeErr != nil { if chromeErr != nil {
return nil, fmt.Errorf("auth_expired") // treat as auth failure if Chrome also fails log.Printf("Chrome fallback failed: %v", chromeErr)
return nil, fmt.Errorf("auth_expired")
} }
log.Printf("Chrome fallback succeeded (%d bytes)", len(chromeBody))
return chromeBody, nil return chromeBody, nil
} }
return nil, fmt.Errorf("HTTP %d", status) return nil, fmt.Errorf("HTTP %d", status)