Add Cloudflare 403 fallback via headless Chrome
All checks were successful
Release / build (push) Successful in 1m40s
All checks were successful
Release / build (push) Successful in 1m40s
Plain HTTP requests to claude.ai get blocked by Cloudflare JS challenges (403). The fetcher now falls back to headless Chrome using the persistent browser profile, which can solve challenges natively and reuses existing cf_clearance cookies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.5.0] — 2026-03-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Cloudflare 403 bypass** — API requests blocked by Cloudflare JS challenges now fall back to headless Chrome with the persistent browser profile, which can solve the challenges natively
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `internal/browser/fetch.go` — headless Chrome API fetcher using chromedp with the existing browser profile (reuses Cloudflare clearance cookies)
|
||||||
|
- `fetchWithFallback()` in fetcher — tries plain HTTP first, falls back to headless Chrome on 403
|
||||||
|
|
||||||
## [0.3.0] — 2026-02-26
|
## [0.3.0] — 2026-02-26
|
||||||
|
|
||||||
Full rewrite from Node.js + Python to Go. Each platform gets a single static binary — no runtime dependencies.
|
Full rewrite from Node.js + Python to Go. Each platform gets a single static binary — no runtime dependencies.
|
||||||
@@ -44,5 +53,6 @@ First tagged release. Includes the CLI statusline, standalone usage fetcher, cro
|
|||||||
- Tray icon visibility — switched to Claude orange with full opacity at larger size
|
- Tray icon visibility — switched to Claude orange with full opacity at larger size
|
||||||
- Block comment syntax error in cron example
|
- Block comment syntax error in cron example
|
||||||
|
|
||||||
|
[0.5.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.5.0
|
||||||
[0.3.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.3.0
|
[0.3.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.3.0
|
||||||
[0.2.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.2.0
|
[0.2.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.2.0
|
||||||
|
|||||||
76
internal/browser/fetch.go
Normal file
76
internal/browser/fetch.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
|
||||||
|
"git.davoryn.de/calic/claude-statusline/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchViaChrome navigates to a URL using headless Chrome with the persistent
|
||||||
|
// browser profile (which has Cloudflare clearance cookies) and returns the
|
||||||
|
// response body. This bypasses Cloudflare JS challenges because Chrome runs
|
||||||
|
// real JavaScript. Falls back to non-headless if headless fails.
|
||||||
|
func FetchViaChrome(url string) ([]byte, error) {
|
||||||
|
profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
|
||||||
|
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create browser profile dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale lock file from unclean shutdown
|
||||||
|
_ = os.Remove(filepath.Join(profileDir, "SingletonLock"))
|
||||||
|
|
||||||
|
execPath := findBrowserExec()
|
||||||
|
|
||||||
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||||
|
chromedp.Flag("headless", true),
|
||||||
|
chromedp.UserDataDir(profileDir),
|
||||||
|
)
|
||||||
|
if execPath != "" {
|
||||||
|
opts = append(opts, chromedp.ExecPath(execPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||||
|
defer allocCancel()
|
||||||
|
|
||||||
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Set a total timeout for the operation
|
||||||
|
ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer timeoutCancel()
|
||||||
|
|
||||||
|
var body string
|
||||||
|
err := chromedp.Run(ctx,
|
||||||
|
chromedp.Navigate(url),
|
||||||
|
// Wait for the body to have content (Cloudflare challenge resolves via JS)
|
||||||
|
chromedp.WaitReady("body"),
|
||||||
|
// Chrome renders JSON API responses inside a <pre> tag
|
||||||
|
chromedp.Text("pre", &body, chromedp.ByQuery, chromedp.NodeVisible),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: try extracting from body directly (some responses may not use <pre>)
|
||||||
|
var bodyFallback string
|
||||||
|
errFb := chromedp.Run(ctx,
|
||||||
|
chromedp.Text("body", &bodyFallback, chromedp.ByQuery),
|
||||||
|
)
|
||||||
|
if errFb == nil && bodyFallback != "" {
|
||||||
|
return []byte(bodyFallback), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("chromedp fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body == "" {
|
||||||
|
return nil, fmt.Errorf("chromedp fetch: empty response body")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gracefully close to flush cookies (including refreshed cf_clearance)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
return []byte(body), nil
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.davoryn.de/calic/claude-statusline/internal/browser"
|
||||||
"git.davoryn.de/calic/claude-statusline/internal/config"
|
"git.davoryn.de/calic/claude-statusline/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,17 +58,36 @@ func doRequest(url, sessionKey string) ([]byte, int, error) {
|
|||||||
return body, resp.StatusCode, nil
|
return body, resp.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchWithFallback tries a plain HTTP request first, then falls back to
|
||||||
|
// headless Chrome (which can solve Cloudflare JS challenges) on 403.
|
||||||
|
func fetchWithFallback(url, sessionKey string) ([]byte, error) {
|
||||||
|
body, status, err := doRequest(url, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
if status == 200 {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
if status == 401 {
|
||||||
|
return nil, fmt.Errorf("auth_expired")
|
||||||
|
}
|
||||||
|
if status == 403 {
|
||||||
|
// Likely a Cloudflare JS challenge — fall back to headless Chrome
|
||||||
|
log.Printf("HTTP 403 for %s, falling back to headless Chrome", url)
|
||||||
|
chromeBody, chromeErr := browser.FetchViaChrome(url)
|
||||||
|
if chromeErr != nil {
|
||||||
|
return nil, fmt.Errorf("auth_expired") // treat as auth failure if Chrome also fails
|
||||||
|
}
|
||||||
|
return chromeBody, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("HTTP %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
// DiscoverOrgID fetches the first organization UUID from the API.
|
// DiscoverOrgID fetches the first organization UUID from the API.
|
||||||
func DiscoverOrgID(sessionKey string) (string, error) {
|
func DiscoverOrgID(sessionKey string) (string, error) {
|
||||||
body, status, err := doRequest(apiBase+"/api/organizations", sessionKey)
|
body, err := fetchWithFallback(apiBase+"/api/organizations", sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
return "", err
|
||||||
}
|
|
||||||
if status == 401 || status == 403 {
|
|
||||||
return "", fmt.Errorf("auth_expired")
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
return "", fmt.Errorf("HTTP %d", status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var orgs []struct {
|
var orgs []struct {
|
||||||
@@ -96,16 +117,13 @@ func FetchUsage(sessionKey, orgID string) (*CacheData, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/organizations/%s/usage", apiBase, orgID)
|
url := fmt.Sprintf("%s/api/organizations/%s/usage", apiBase, orgID)
|
||||||
body, status, err := doRequest(url, sessionKey)
|
body, err := fetchWithFallback(url, sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err.Error() == "auth_expired" {
|
||||||
|
return &CacheData{Error: "auth_expired", Status: 403}, orgID, err
|
||||||
|
}
|
||||||
return &CacheData{Error: "fetch_failed", Message: err.Error()}, orgID, err
|
return &CacheData{Error: "fetch_failed", Message: err.Error()}, orgID, err
|
||||||
}
|
}
|
||||||
if status == 401 || status == 403 {
|
|
||||||
return &CacheData{Error: "auth_expired", Status: status}, orgID, fmt.Errorf("auth_expired")
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
return &CacheData{Error: "api_error", Status: status}, orgID, fmt.Errorf("HTTP %d", status)
|
|
||||||
}
|
|
||||||
|
|
||||||
var data CacheData
|
var data CacheData
|
||||||
if err := json.Unmarshal(body, &data); err != nil {
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user