From e1f4aca10ed93583359e7670f8b2bdcc6454d6b1 Mon Sep 17 00:00:00 2001 From: Diego Souto Date: Sat, 11 Jul 2026 20:33:00 +0200 Subject: [PATCH] feat: add remote browser CDP control support Add support for connecting to remote CDP-compatible browsers (e.g., Obscura) instead of launching a local browser instance. Changes: - Add --browser-control-url flag to specify remote CDP WebSocket URL - Add --delegate-stealth flag to skip local stealth patching when remote browser handles anti-detection features - Update BrowserOpts to support remote CDP connection - Add configuration options in config.yaml for remote browser setup This enables using managed browser services like Obscura as drop-in replacements for local Chrome/Chromium instances while maintaining full CDP compatibility. Tests: All existing tests pass (make test) --- cmd/root.go | 37 ++++++++++++++++++++++++------------- cmd/serve.go | 16 +++++++++------- core/browser.go | 31 +++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 22 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 6cf7754..ae91613 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -70,6 +70,11 @@ type AppConfig struct { MaxProcesses int `mapstructure:"max_processes"` IdleTTL time.Duration `mapstructure:"idle_ttl"` MegaTimeout time.Duration `mapstructure:"mega_timeout"` + // BrowserControlURL optionally points to an existing CDP control URL (ws://...) instead of launching a local browser. + BrowserControlURL string `mapstructure:"browser_control_url"` + // DelegateStealth instructs the server to skip local stealth patching and + // rely on the remote browser (eg. Obscura) to provide stealth/covert features. + DelegateStealth bool `mapstructure:"delegate_stealth"` } type EngineConfig struct { @@ -178,19 +183,21 @@ func sanitizedConfigForLog(cfg Config) map[string]interface{} { return map[string]interface{}{ "server": cfg.Server, "app": map[string]interface{}{ - "timeout": cfg.App.Timeout, - "browser_path": cfg.App.BrowserPath != "", - "profiles": cfg.App.ProfilesJSON != "", - "head": cfg.App.IsBrowserHead, - "leave_head": cfg.App.IsLeaveHead, - "leakless": cfg.App.IsLeakless, - "block_resources": cfg.App.BlockResources, - "block_trackers": cfg.App.BlockTrackers, - "debug_endpoints": cfg.App.DebugEndpoints, - "log_format": cfg.App.LogFormat, - "max_processes": cfg.App.MaxProcesses, - "idle_ttl": cfg.App.IdleTTL.String(), - "mega_timeout": cfg.App.MegaTimeout.String(), + "timeout": cfg.App.Timeout, + "browser_path": cfg.App.BrowserPath != "", + "profiles": cfg.App.ProfilesJSON != "", + "head": cfg.App.IsBrowserHead, + "leave_head": cfg.App.IsLeaveHead, + "leakless": cfg.App.IsLeakless, + "block_resources": cfg.App.BlockResources, + "block_trackers": cfg.App.BlockTrackers, + "debug_endpoints": cfg.App.DebugEndpoints, + "log_format": cfg.App.LogFormat, + "max_processes": cfg.App.MaxProcesses, + "idle_ttl": cfg.App.IdleTTL.String(), + "mega_timeout": cfg.App.MegaTimeout.String(), + "browser_control": cfg.App.BrowserControlURL != "", + "delegate_stealth": cfg.App.DelegateStealth, }, "proxies": map[string]interface{}{ "global": maskedProxyForLog(cfg.Proxies.Global), @@ -411,6 +418,8 @@ func setConfigDefaults(v *viper.Viper) { v.SetDefault("app.max_processes", 4) v.SetDefault("app.idle_ttl", "10m") v.SetDefault("app.mega_timeout", "90s") + v.SetDefault("app.browser_control_url", "") + v.SetDefault("app.delegate_stealth", false) v.SetDefault("proxies.entries", []interface{}{}) v.SetDefault("proxies.global", "") @@ -449,6 +458,8 @@ func init() { RootCmd.PersistentFlags().StringVarP(&config.Server.ConfigPath, "config", "c", "", "Configuration file path") RootCmd.PersistentFlags().StringVarP(&config.App.BrowserPath, "browser-path", "", "", "Custom browser binary path (Chrome/Chromium/Edge/Brave..)") RootCmd.PersistentFlags().StringVar(&config.App.ProfilesJSON, "profiles", "", "Path to browser profile catalog JSON") + RootCmd.PersistentFlags().StringVar(&config.App.BrowserControlURL, "browser-control-url", "", "Remote CDP control URL (ws://...) to use instead of launching local browser") + RootCmd.PersistentFlags().BoolVar(&config.App.DelegateStealth, "delegate-stealth", false, "Delegate stealth/anti-detection handling to the remote browser (eg. Obscura)") RootCmd.PersistentFlags().BoolVarP(&config.Server.IsVerbose, "verbose", "v", false, "Use verbose output") RootCmd.PersistentFlags().BoolVarP(&config.Server.IsDebug, "debug", "d", false, "Use debug output. Disable headless browser") RootCmd.PersistentFlags().BoolVarP(&config.Server.IsQuiet, "quiet", "q", false, "Suppress info logs on stderr (default for CLI commands)") diff --git a/cmd/serve.go b/cmd/serve.go index 24cfd0a..8906f3c 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -132,13 +132,15 @@ func buildFingerprintBrowserOptions() core.BrowserOpts { blockedResourceTypes := core.MustParseBlockedResourceTypes(config.App.BlockResources) opts := core.BrowserOpts{ - IsHeadless: !config.App.IsBrowserHead, - IsLeakless: config.App.IsLeakless, - Timeout: time.Second * time.Duration(config.App.Timeout), - BrowserPath: config.App.BrowserPath, - Insecure: config.Server.Insecure, - BlockResourceTypes: blockedResourceTypes, - BlockTrackers: config.App.BlockTrackers, + IsHeadless: !config.App.IsBrowserHead, + IsLeakless: config.App.IsLeakless, + Timeout: time.Second * time.Duration(config.App.Timeout), + BrowserPath: config.App.BrowserPath, + BrowserControlURL: config.App.BrowserControlURL, + DelegateStealthToRemote: config.App.DelegateStealth, + Insecure: config.Server.Insecure, + BlockResourceTypes: blockedResourceTypes, + BlockTrackers: config.App.BlockTrackers, } if config.Server.IsDebug { opts.IsHeadless = false diff --git a/core/browser.go b/core/browser.go index c412e9c..87c1bad 100644 --- a/core/browser.go +++ b/core/browser.go @@ -53,6 +53,11 @@ type BrowserOpts struct { Insecure bool // UserAgent optionally overrides browser-reported user agent during emulation. UserAgent string + // BrowserControlURL optionally points to an existing CDP control URL (ws://...) instead of launching a local browser. + BrowserControlURL string + // DelegateStealthToRemote, when true, skips running the local stealth/patch JS + // because the remote browser (eg. Obscura) will handle stealth/covert behaviour. + DelegateStealthToRemote bool // BlockResourceTypes are blocked during page navigation when non-empty. // Typical tokens map to these types: image, font, css(stylesheet), js(script), media. BlockResourceTypes []proto.NetworkResourceType @@ -274,6 +279,23 @@ func NewBrowser(opts BrowserOpts) (*Browser, error) { } logrus.WithFields(browserOptsLogFields(opts)).Debug("Browser options") + // If a remote CDP control URL is provided (e.g. Obscura), use it instead of + // launching a local browser. This makes Obscura a drop-in replacement for + // Chrome for the CDP-based integration. + if strings.TrimSpace(opts.BrowserControlURL) != "" { + b := Browser{ + conn: &browserConnection{}, + } + b.BrowserOpts = opts + b.browserAddr = strings.TrimSpace(opts.BrowserControlURL) + + if opts.CaptchaSolverEnabled && opts.CaptchaSolverApiKey != "" { + b.CaptchaSolver = NewSolver(opts.CaptchaSolverApiKey) + logrus.Debug("Captcha solver initialized") + } + return &b, nil + } + path, err := resolveBrowserBinaryPath(opts.BrowserPath, launcher.LookPath) if err != nil { return nil, err @@ -362,6 +384,8 @@ func browserOptsLogFields(opts BrowserOpts) logrus.Fields { "block_resource_types": len(opts.BlockResourceTypes), "block_trackers": opts.BlockTrackers, "proxy_lanes_enabled": opts.ProxyLaneStore != nil, + "browser_control_url": strings.TrimSpace(opts.BrowserControlURL) != "", + "delegate_stealth": opts.DelegateStealthToRemote, } } @@ -1460,11 +1484,14 @@ func (b *Browser) Navigate(ctx context.Context, URL string) (*rod.Page, error) { profile, laneKey := b.laneProfile(ctx, browser) SetBrowserProfileID(ctx, profile.ID) minimalProfile := minimalBrowserProfileFromContext(ctx) + // If stealth is delegated to the remote browser (eg. Obscura), skip local + // worker/script stealth patching while still applying UA/locale/headers. + effectiveMinimal := minimalProfile || b.DelegateStealthToRemote WithRequest(ctx).WithFields(logrus.Fields{ "lane_id": laneKey, "minimal_profile": minimalProfile, }).Info("Browser profile selected") - if err := applyProfile(page, profile, minimalProfile); err != nil { + if err := applyProfile(page, profile, effectiveMinimal); err != nil { closeOnErr() return nil, fmt.Errorf("apply profile %s (%s) failed: %w", profile.ID, laneKey, err) } @@ -1474,7 +1501,7 @@ func (b *Browser) Navigate(ctx context.Context, URL string) (*rod.Page, error) { } page = page.Context(ctx) - if !minimalProfile { + if !effectiveMinimal { metrics := profileDisplayMetricsFor(profile) patchScript, err := buildProfilePatchScript(profile, profileNavigatorLanguages(profile), metrics) if err != nil {