Proxy policy wiring and fail-closed execution

- replace proxy wiring with global and per-engine tag policies
- split stats endpoints and lock fail-closed proxy behavior with tests
This commit is contained in:
Rustem Kamalov
2026-04-01 00:54:21 +03:00
parent 8bec5578c0
commit 3daa48e6a3
15 changed files with 1925 additions and 603 deletions

30
cmd/proxy_policy.go Normal file
View File

@@ -0,0 +1,30 @@
package cmd
import (
"strings"
"github.com/karust/openserp/core"
)
func buildEngineProxyPolicyMap() map[string]string {
return map[string]string{
"google": config.GoogleConfig.Proxy,
"yandex": config.YandexConfig.Proxy,
"baidu": config.BaiduConfig.Proxy,
"bing": config.BingConfig.Proxy,
"duckduckgo": config.DuckDuckGoConfig.Proxy,
}
}
func buildNormalizedProxyConfig(runtime string) (core.ProxyConfig, error) {
return core.NormalizeProxyConfig(core.ProxyConfig{
Runtime: runtime,
Proxies: config.Proxies,
EnginePolicies: buildEngineProxyPolicyMap(),
})
}
func resolveEngineProxyPolicy(proxyCfg core.ProxyConfig, engineName string) core.ProxyPolicy {
engineKey := strings.ToLower(strings.TrimSpace(engineName))
return core.ResolveEffectiveProxyPolicy(proxyCfg.Proxies.Global, proxyCfg.EnginePolicies[engineKey])
}

View File

@@ -19,44 +19,47 @@ const (
)
type Config struct {
App AppConfig `mapstructure:"app"`
ProxyPool ProxyPoolConfig `mapstructure:"proxy_pool"`
Cache CacheConfig `mapstructure:"cache"`
Resilience ResilienceConfig `mapstructure:"resilience"`
CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"`
CORS CORSConfig `mapstructure:"cors"`
Config2Capcha Config2Captcha `mapstructure:"2captcha"`
GoogleConfig core.SearchEngineOptions `mapstructure:"google"`
YandexConfig core.SearchEngineOptions `mapstructure:"yandex"`
BaiduConfig core.SearchEngineOptions `mapstructure:"baidu"`
BingConfig core.SearchEngineOptions `mapstructure:"bing"`
DuckDuckGoConfig core.SearchEngineOptions `mapstructure:"duckduckgo"`
Server ServerConfig `mapstructure:"server"`
App AppConfig `mapstructure:"app"`
Proxies core.ProxiesConfig `mapstructure:"proxies"`
Cache CacheConfig `mapstructure:"cache"`
Resilience ResilienceConfig `mapstructure:"resilience"`
CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"`
CORS CORSConfig `mapstructure:"cors"`
Config2Capcha Config2Captcha `mapstructure:"2captcha"`
GoogleConfig EngineConfig `mapstructure:"google"`
YandexConfig EngineConfig `mapstructure:"yandex"`
BaiduConfig EngineConfig `mapstructure:"baidu"`
BingConfig EngineConfig `mapstructure:"bing"`
DuckDuckGoConfig EngineConfig `mapstructure:"duckduckgo"`
}
type Config2Captcha struct {
ApiKey string `mapstructure:"apikey"`
}
type AppConfig struct {
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Timeout int `mapstructure:"timeout"`
ConfigPath string `mapstructure:"config_path"`
IsDebug bool `mapstructure:"debug"`
IsVerbose bool `mapstructure:"verbose"`
IsRawRequests bool `mapstructure:"raw_requests"`
Insecure bool `mapstructure:"insecure"`
}
type AppConfig struct {
Timeout int `mapstructure:"timeout"`
BrowserPath string `mapstructure:"browser_path"`
IsBrowserHead bool `mapstructure:"head"`
IsLeaveHead bool `mapstructure:"leave_head"`
IsLeakless bool `mapstructure:"leakless"`
IsDebug bool `mapstructure:"debug"`
IsVerbose bool `mapstructure:"verbose"`
IsRawRequests bool `mapstructure:"raw_requests"`
ProxyURL string `mapstructure:"proxy"`
Insecure bool `mapstructure:"insecure"`
IsStealth bool `mapstructure:"stealth"`
}
type ProxyPoolConfig struct {
URLs []string `mapstructure:"urls"`
FailureThreshold int `mapstructure:"failure_threshold"`
type EngineConfig struct {
core.SearchEngineOptions `mapstructure:",squash"`
Proxy string `mapstructure:"proxy"`
}
type CacheConfig struct {
@@ -86,11 +89,21 @@ type CORSConfig struct {
var config = Config{}
var flagToConfigKey = map[string]string{
"config": "app.config_path",
"host": "server.host",
"port": "server.port",
"timeout": "app.timeout",
"config": "server.config_path",
"browser-path": "app.browser_path",
"verbose": "server.verbose",
"debug": "server.debug",
"head": "app.head",
"leakless": "app.leakless",
"raw": "server.raw_requests",
"leave": "app.leave_head",
"raw": "app.raw_requests",
"2captcha_key": "2captcha.apikey",
"proxy": "proxies.global",
"stealth": "app.stealth",
"insecure": "server.insecure",
"cache_ttl": "cache.ttl_seconds",
"cache_max_size": "cache.max_size",
"max_retries": "resilience.max_retries",
@@ -107,22 +120,15 @@ var RootCmd = &cobra.Command{
Version: version,
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
core.InitLogger(config.App.IsVerbose, config.App.IsDebug)
err := initializeConfig(cmd)
if err != nil {
return err
}
core.InitLogger(config.Server.IsVerbose, config.Server.IsDebug)
logrus.Debugf("Final config: %+v", config)
return nil
},
// Run: func(cmd *cobra.Command, args []string) {
// // Working with OutOrStdout/OutOrStderr allows us to unit test our command easier
// //out := cmd.OutOrStdout()
// logrus.Trace("Config:", config)
// },
}
// Bind each cobra flag to its associated viper configuration (config file and environment variable)
@@ -189,35 +195,107 @@ func initializeConfig(cmd *cobra.Command) error {
// 3. Command flags (highest priority). Bind the current command's flags to viper
bindFlags(cmd, v)
if err := validateRemovedConfigPaths(v); err != nil {
return err
}
// Dump Viper values to config struct
if err := validateEngineProxyTags(v); err != nil {
return err
}
err = v.Unmarshal(&config)
if err != nil {
return fmt.Errorf("cannot unmarshall config: %v", err)
}
config.App.ProxyURL, err = core.NormalizeProxyURL(config.App.ProxyURL)
config.Proxies, err = core.NormalizeProxiesConfig(config.Proxies)
if err != nil {
return fmt.Errorf("invalid app.proxy: %w", err)
return fmt.Errorf("invalid proxies config: %w", err)
}
config.ProxyPool.URLs, err = core.NormalizeProxyURLs(config.ProxyPool.URLs)
if err != nil {
return fmt.Errorf("invalid proxy_pool.urls: %w", err)
}
if config.ProxyPool.FailureThreshold <= 0 {
config.ProxyPool.FailureThreshold = core.DefaultProxyPoolFailureThreshold
}
if config.App.IsDebug {
if config.Server.IsDebug {
logrus.Debug("Viper config:")
v.Debug()
}
return nil
}
func validateEngineProxyTags(v *viper.Viper) error {
for _, engineName := range []string{"google", "yandex", "baidu", "bing", "duckduckgo"} {
key := engineName + ".proxy"
if !v.IsSet(key) {
continue
}
raw := v.Get(key)
tag, ok := raw.(string)
if !ok {
return fmt.Errorf("invalid %s.proxy config: proxy must be a string tag", engineName)
}
if _, err := core.NormalizeProxyTag(tag); err != nil {
return fmt.Errorf("invalid %s.proxy config: %w", engineName, err)
}
}
return nil
}
func validateRemovedConfigPaths(v *viper.Viper) error {
legacyKeys := map[string]string{
"app.proxy": "use proxies.global or proxies.entries with per-engine proxy tags instead",
"proxy_pool": "use proxies.entries and proxies.health.failure_threshold instead",
"proxy_pool.urls": "use proxies.entries instead",
"proxy_pool.failure_threshold": "use proxies.health.failure_threshold instead",
"app.host": "move to server.host",
"app.port": "move to server.port",
"app.debug": "move to server.debug",
"app.verbose": "move to server.verbose",
"app.raw_requests": "move to server.raw_requests",
"app.insecure": "move to server.insecure",
"proxies.defaults": "use proxies.global or per-engine proxy tags instead",
"proxies.defaults.mode": "use proxies.global or per-engine proxy tags instead",
"proxies.defaults.tag": "use per-engine proxy tags on each engine instead",
"google.proxy.mode": "use google.proxy: <tag> or omit it for direct mode",
"google.proxy.tag": "use google.proxy: <tag>",
"yandex.proxy.mode": "use yandex.proxy: <tag> or omit it for direct mode",
"yandex.proxy.tag": "use yandex.proxy: <tag>",
"baidu.proxy.mode": "use baidu.proxy: <tag> or omit it for direct mode",
"baidu.proxy.tag": "use baidu.proxy: <tag>",
"bing.proxy.mode": "use bing.proxy: <tag> or omit it for direct mode",
"bing.proxy.tag": "use bing.proxy: <tag>",
"duckduckgo.proxy.mode": "use duckduckgo.proxy: <tag> or omit it for direct mode",
"duckduckgo.proxy.tag": "use duckduckgo.proxy: <tag>",
}
for key, hint := range legacyKeys {
if v.IsSet(key) {
return fmt.Errorf("config key %q is removed in proxy v2: %s", key, hint)
}
}
return nil
}
func setConfigDefaults(v *viper.Viper) {
v.SetDefault("proxy_pool.urls", []string{})
v.SetDefault("proxy_pool.failure_threshold", core.DefaultProxyPoolFailureThreshold)
v.SetDefault("server.host", "127.0.0.1")
v.SetDefault("server.port", 7070)
v.SetDefault("server.debug", false)
v.SetDefault("server.verbose", false)
v.SetDefault("server.raw_requests", false)
v.SetDefault("server.insecure", false)
v.SetDefault("app.timeout", 30)
v.SetDefault("app.browser_path", "")
v.SetDefault("app.head", false)
v.SetDefault("app.leave_head", false)
v.SetDefault("app.leakless", false)
v.SetDefault("app.stealth", false)
v.SetDefault("proxies.entries", []interface{}{})
v.SetDefault("proxies.global", "")
v.SetDefault("proxies.health.failure_threshold", core.DefaultProxyFailureThreshold)
v.SetDefault("cache.ttl_seconds", 300)
v.SetDefault("cache.max_size", 1000)
// Keep stage2 defaults stable even when config file is absent.
@@ -234,21 +312,21 @@ func setConfigDefaults(v *viper.Viper) {
}
func init() {
RootCmd.PersistentFlags().IntVarP(&config.App.Port, "port", "p", 7070, "Port number to run server")
RootCmd.PersistentFlags().StringVarP(&config.App.Host, "host", "a", "127.0.0.1", "Host address to run server")
RootCmd.PersistentFlags().IntVarP(&config.Server.Port, "port", "p", 7070, "Port number to run server")
RootCmd.PersistentFlags().StringVarP(&config.Server.Host, "host", "a", "127.0.0.1", "Host address to run server")
RootCmd.PersistentFlags().IntVarP(&config.App.Timeout, "timeout", "t", 30, "Timeout to fail request")
RootCmd.PersistentFlags().StringVarP(&config.App.ConfigPath, "config", "c", "", "Configuration file path")
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().BoolVarP(&config.App.IsVerbose, "verbose", "v", false, "Use verbose output")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsDebug, "debug", "d", false, "Use debug output. Disable headless browser")
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.App.IsBrowserHead, "head", "", false, "Enable browser UI")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsLeakless, "leakless", "l", false, "Use leakless mode to insure browser instances are closed after search")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsRawRequests, "raw", "r", false, "Disable browser usage, use HTTP requests")
RootCmd.PersistentFlags().BoolVarP(&config.Server.IsRawRequests, "raw", "r", false, "Disable browser usage, use HTTP requests")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsLeaveHead, "leave", "", false, "Leave browser and tabs opened after search is made")
RootCmd.PersistentFlags().StringVarP(&config.Config2Capcha.ApiKey, "2captcha_key", "", "", "2 captcha api key")
RootCmd.PersistentFlags().StringVarP(&config.App.ProxyURL, "proxy", "x", "", "HTTP/HTTPS/SOCKS5/SOCKS5H proxy URL (e.g. socks5h://127.0.0.1:1080)")
RootCmd.PersistentFlags().StringVarP(&config.Proxies.Global, "proxy", "x", "", "Force a single proxy for all engines (same as proxies.global)")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsStealth, "stealth", "s", false, "Use stealth browser plugin")
RootCmd.PersistentFlags().BoolVarP(&config.App.Insecure, "insecure", "k", false, "Allow insecure TLS connections")
RootCmd.PersistentFlags().BoolVarP(&config.Server.Insecure, "insecure", "k", false, "Allow insecure TLS connections")
RootCmd.PersistentFlags().IntVar(&config.Cache.TTLSeconds, "cache_ttl", 300, "Cache TTL in seconds (0 to disable)")
RootCmd.PersistentFlags().IntVar(&config.Cache.MaxSize, "cache_max_size", 1000, "Maximum number of cached responses")
RootCmd.PersistentFlags().IntVar(&config.Resilience.MaxRetries, "max_retries", 3, "Max retry attempts per search engine (0 to disable)")

View File

@@ -25,25 +25,46 @@ var searchCMD = &cobra.Command{
}
func search(cmd *cobra.Command, args []string) {
var err error
engineType := args[0]
engineType := normalizeEngineArg(args[0])
query := core.Query{
Text: args[1],
Limit: 10,
Filter: true,
ProxyURL: config.App.ProxyURL,
Insecure: config.App.Insecure,
Insecure: config.Server.Insecure,
}
proxyRuntime := core.ProxyRuntimeBrowser
if config.Server.IsRawRequests {
proxyRuntime = core.ProxyRuntimeRaw
}
proxyCfg, err := buildNormalizedProxyConfig(proxyRuntime)
if err != nil {
logrus.Errorf("Error validating proxy config: %v", err)
return
}
policy := resolveEngineProxyPolicy(proxyCfg, engineType)
selectedProxy, err := selectCLIProxy(proxyCfg, policy)
if err != nil {
logrus.Errorf("Error selecting proxy for %s: %v", engineType, err)
return
}
if config.Server.IsRawRequests {
query.ProxyURL = selectedProxy
}
logrus.Infof("Starting SERP search request using %s engine for query: %s", engineType, query.Text)
var results []core.SearchResult
if config.App.IsRawRequests {
if config.Server.IsRawRequests {
logrus.Infof("Using raw requests mode for %s search", engineType)
results, err = searchRaw(engineType, query)
} else {
logrus.Infof("Using browser mode for %s search", engineType)
results, err = searchBrowser(engineType, query)
results, err = searchBrowser(engineType, query, selectedProxy)
}
if err != nil {
@@ -61,43 +82,51 @@ func search(cmd *cobra.Command, args []string) {
fmt.Println(string(b))
}
func searchBrowser(engineType string, query core.Query) ([]core.SearchResult, error) {
func searchBrowser(engineType string, query core.Query, browserProxyURL string) ([]core.SearchResult, error) {
var engine core.SearchEngine
if core.IsAuthenticatedSocksProxyURL(browserProxyURL) {
return nil, fmt.Errorf(
"%w: browser runtime does not support authenticated SOCKS proxy %s",
core.ErrProxyUnavailable,
core.MaskProxyURL(browserProxyURL),
)
}
opts := core.BrowserOpts{
IsHeadless: !config.App.IsBrowserHead, // Disable headless if browser head mode is set
IsHeadless: !config.App.IsBrowserHead,
IsLeakless: config.App.IsLeakless,
Timeout: time.Second * time.Duration(config.App.Timeout),
LeavePageOpen: config.App.IsLeaveHead,
CaptchaSolverApiKey: config.Config2Capcha.ApiKey,
BrowserPath: config.App.BrowserPath,
ProxyURL: config.App.ProxyURL,
Insecure: config.App.Insecure,
ProxyURL: browserProxyURL,
Insecure: config.Server.Insecure,
UseStealth: config.App.IsStealth,
}
if config.App.IsDebug {
if config.Server.IsDebug {
opts.IsHeadless = false
}
browser, err := core.NewBrowser(opts)
if err != nil {
logrus.Error(err)
return nil, err
}
switch strings.ToLower(engineType) {
case "yandex":
engine = yandex.New(*browser, config.YandexConfig)
engine = yandex.New(*browser, config.YandexConfig.SearchEngineOptions)
case "google":
engine = google.New(*browser, config.GoogleConfig)
engine = google.New(*browser, config.GoogleConfig.SearchEngineOptions)
case "baidu":
engine = baidu.New(*browser, config.BaiduConfig)
engine = baidu.New(*browser, config.BaiduConfig.SearchEngineOptions)
case "bing":
engine = bing.New(*browser, config.BingConfig)
case "duck":
engine = duckduckgo.New(*browser, config.DuckDuckGoConfig)
engine = bing.New(*browser, config.BingConfig.SearchEngineOptions)
case "duckduckgo":
engine = duckduckgo.New(*browser, config.DuckDuckGoConfig.SearchEngineOptions)
default:
logrus.Infof("No `%s` search engine found", engineType)
return nil, fmt.Errorf("no %q search engine found", engineType)
}
return engine.Search(query)
@@ -116,13 +145,42 @@ func searchRaw(engineType string, query core.Query) ([]core.SearchResult, error)
case "bing":
logrus.Warn("Bing does not support raw HTTP requests mode. Please use browser mode instead.")
return nil, fmt.Errorf("bing does not support raw requests mode")
case "duck":
case "duckduckgo":
logrus.Warn("DuckDuckGo does not support raw HTTP requests mode. Please use browser mode instead.")
return nil, fmt.Errorf("duckduckgo does not support raw requests mode")
default:
logrus.Infof("No `%s` search engine found", engineType)
return nil, fmt.Errorf("no %q search engine found", engineType)
}
}
func selectCLIProxy(proxyCfg core.ProxyConfig, policy core.ProxyPolicy) (string, error) {
if policy.Mode == core.ProxyModeOff {
return "", nil
}
if global := strings.TrimSpace(proxyCfg.Proxies.Global); global != "" {
return global, nil
}
if proxyCfg.Registry == nil {
return "", fmt.Errorf("%w: no proxy registry configured", core.ErrProxyUnavailable)
}
selected := proxyCfg.Registry.NextByTag(policy.Tag)
if selected == "" {
return "", fmt.Errorf("%w: no healthy proxy available for tag %q", core.ErrProxyUnavailable, policy.Tag)
}
return selected, nil
}
func normalizeEngineArg(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "duck":
return "duckduckgo"
default:
return strings.ToLower(strings.TrimSpace(raw))
}
return nil, nil
}
func init() {

View File

@@ -2,6 +2,8 @@ package cmd
import (
"fmt"
"strings"
"sync"
"time"
"github.com/karust/openserp/baidu"
@@ -21,7 +23,7 @@ type rawEngine struct {
}
func (r *rawEngine) Search(q core.Query) ([]core.SearchResult, error) {
q.Insecure = config.App.Insecure
q.Insecure = config.Server.Insecure
switch r.name {
case "google":
@@ -67,17 +69,60 @@ func serve(cmd *cobra.Command, args []string) {
corsCfg.AllowHeaders = config.CORS.AllowHeaders
corsCfg.MaxAge = config.CORS.MaxAge
proxyCfg := core.ProxyConfig{
Runtime: core.ProxyRuntimeBrowser,
StaticURL: config.App.ProxyURL,
PoolURLs: config.ProxyPool.URLs,
PoolFailureThreshold: config.ProxyPool.FailureThreshold,
}
if config.App.IsRawRequests {
proxyCfg.Runtime = core.ProxyRuntimeRaw
proxyRuntime := core.ProxyRuntimeBrowser
if config.Server.IsRawRequests {
proxyRuntime = core.ProxyRuntimeRaw
}
serverOpts := core.ServerOptions{
proxyCfg, err := buildNormalizedProxyConfig(proxyRuntime)
if err != nil {
logrus.Errorf("invalid proxy configuration: %v", err)
return
}
if config.Server.IsRawRequests {
logrus.Warn("Browserless results are very inconsistent or may not even work!")
serverOpts := buildServerOptions(corsCfg, proxyCfg)
serv := core.NewServerWithOptions(config.Server.Host, config.Server.Port, serverOpts,
&rawEngine{name: "google"},
&rawEngine{name: "yandex"},
&rawEngine{name: "baidu"},
)
if err := serv.Listen(); err != nil {
logrus.Error(err)
}
return
}
baseOpts := core.BrowserOpts{
IsHeadless: !config.App.IsBrowserHead,
IsLeakless: config.App.IsLeakless,
Timeout: time.Second * time.Duration(config.App.Timeout),
LeavePageOpen: config.App.IsLeaveHead,
CaptchaSolverApiKey: config.Config2Capcha.ApiKey,
BrowserPath: config.App.BrowserPath,
Insecure: config.Server.Insecure,
UseStealth: config.App.IsStealth,
}
if config.Server.IsDebug {
baseOpts.IsHeadless = false
}
engines, err := buildBrowserEngines(baseOpts, proxyCfg)
if err != nil {
logrus.Error(err)
return
}
serverOpts := buildServerOptions(corsCfg, proxyCfg)
serv := core.NewServerWithOptions(config.Server.Host, config.Server.Port, serverOpts, engines...)
if err := serv.Listen(); err != nil {
logrus.Error(err)
}
}
func buildServerOptions(corsCfg core.CORSConfig, proxyCfg core.ProxyConfig) core.ServerOptions {
return core.ServerOptions{
CacheTTL: time.Duration(config.Cache.TTLSeconds) * time.Second,
CacheMaxSize: config.Cache.MaxSize,
EnableCORS: config.CORS.Enabled,
@@ -98,52 +143,227 @@ func serve(cmd *cobra.Command, args []string) {
Proxy: proxyCfg,
},
}
}
if config.App.IsRawRequests {
logrus.Warn("Browserless results are very inconsistent or may not even work!")
serv := core.NewServerWithOptions(config.App.Host, config.App.Port, serverOpts,
&rawEngine{name: "google"},
&rawEngine{name: "yandex"},
&rawEngine{name: "baidu"},
type browserPool struct {
mu sync.Mutex
base core.BrowserOpts
browser map[string]*core.Browser
}
func newBrowserPool(base core.BrowserOpts) *browserPool {
return &browserPool{
base: base,
browser: map[string]*core.Browser{},
}
}
func (p *browserPool) get(proxyURL string) (*core.Browser, error) {
key := strings.TrimSpace(proxyURL)
if key == "" {
key = "direct"
}
p.mu.Lock()
defer p.mu.Unlock()
if b, ok := p.browser[key]; ok {
return b, nil
}
opts := p.base
opts.ProxyURL = proxyURL
b, err := core.NewBrowser(opts)
if err != nil {
return nil, err
}
// Reuse one launched browser per unique effective proxy so startup stays lazy
// and engines with identical proxy policy don't spawn duplicate browser processes.
p.browser[key] = b
return b, nil
}
type pooledBrowserEngine struct {
name string
limiter *rate.Limiter
opts core.SearchEngineOptions
factory func(core.Browser, core.SearchEngineOptions) core.SearchEngine
pool *browserPool
mu sync.Mutex
engines map[string]core.SearchEngine
}
func (e *pooledBrowserEngine) Search(q core.Query) ([]core.SearchResult, error) {
engine, err := e.getOrCreate(q.ProxyURL)
if err != nil {
return nil, err
}
return engine.Search(q)
}
func (e *pooledBrowserEngine) SearchImage(q core.Query) ([]core.SearchResult, error) {
engine, err := e.getOrCreate(q.ProxyURL)
if err != nil {
return nil, err
}
return engine.SearchImage(q)
}
func (e *pooledBrowserEngine) IsInitialized() bool {
return true
}
func (e *pooledBrowserEngine) Name() string {
return e.name
}
func (e *pooledBrowserEngine) GetRateLimiter() *rate.Limiter {
return e.limiter
}
func (e *pooledBrowserEngine) getOrCreate(proxyURL string) (core.SearchEngine, error) {
key := strings.TrimSpace(proxyURL)
if key == "" {
key = "direct"
}
e.mu.Lock()
defer e.mu.Unlock()
if engine, ok := e.engines[key]; ok {
return engine, nil
}
browser, err := e.pool.get(proxyURL)
if err != nil {
return nil, err
}
engine := e.factory(*browser, e.opts)
e.engines[key] = engine
return engine, nil
}
type browserEngineSpec struct {
name string
opts core.SearchEngineOptions
factory func(core.Browser, core.SearchEngineOptions) core.SearchEngine
}
func browserEngineSpecs() []browserEngineSpec {
return []browserEngineSpec{
{
name: "google",
opts: config.GoogleConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return google.New(browser, opts)
},
},
{
name: "yandex",
opts: config.YandexConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return yandex.New(browser, opts)
},
},
{
name: "baidu",
opts: config.BaiduConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return baidu.New(browser, opts)
},
},
{
name: "bing",
opts: config.BingConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return bing.New(browser, opts)
},
},
{
name: "duckduckgo",
opts: config.DuckDuckGoConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return duckduckgo.New(browser, opts)
},
},
}
}
func buildBrowserEngines(baseOpts core.BrowserOpts, proxyCfg core.ProxyConfig) ([]core.SearchEngine, error) {
pool := newBrowserPool(baseOpts)
specs := browserEngineSpecs()
engines := make([]core.SearchEngine, 0, len(specs))
for _, spec := range specs {
policy := resolveEngineProxyPolicy(proxyCfg, spec.name)
if err := validateBrowserProxyPolicy(proxyCfg, policy); err != nil {
return nil, fmt.Errorf("browser proxy validation failed for engine %s: %w", spec.name, err)
}
opts := spec.opts
opts.Init()
engines = append(engines, &pooledBrowserEngine{
name: spec.name,
limiter: rate.NewLimiter(rate.Every(opts.GetRatelimit()), opts.RateBurst),
opts: opts,
factory: spec.factory,
pool: pool,
engines: map[string]core.SearchEngine{},
})
}
return engines, nil
}
func validateBrowserProxyPolicy(proxyCfg core.ProxyConfig, policy core.ProxyPolicy) error {
if policy.Mode != core.ProxyModeTagPool {
return nil
}
proxyURL := strings.TrimSpace(proxyCfg.Proxies.Global)
if proxyURL != "" {
return validateBrowserProxyURL(proxyURL)
}
for _, entry := range proxyCfg.Proxies.Entries {
if !entryHasTag(entry, policy.Tag) {
continue
}
if err := validateBrowserProxyURL(entry.URL); err != nil {
return err
}
}
return nil
}
func validateBrowserProxyURL(proxyURL string) error {
// Browser startup must stop immediately on authenticated SOCKS because Chrome
// cannot use that proxy shape reliably and retrying a different proxy hides the misconfiguration.
if core.IsAuthenticatedSocksProxyURL(proxyURL) {
return fmt.Errorf(
"%w: browser runtime does not support authenticated SOCKS proxy %s",
core.ErrProxyUnavailable,
core.MaskProxyURL(proxyURL),
)
serv.Listen()
return
}
return nil
}
opts := core.BrowserOpts{
IsHeadless: !config.App.IsBrowserHead, // Disable headless if browser head mode is set
IsLeakless: config.App.IsLeakless,
Timeout: time.Second * time.Duration(config.App.Timeout),
LeavePageOpen: config.App.IsLeaveHead,
CaptchaSolverApiKey: config.Config2Capcha.ApiKey,
BrowserPath: config.App.BrowserPath,
ProxyURL: config.App.ProxyURL,
Insecure: config.App.Insecure,
UseStealth: config.App.IsStealth,
func entryHasTag(entry core.ProxyEntryConfig, tag string) bool {
tag = strings.TrimSpace(strings.ToLower(tag))
if tag == "" {
return false
}
if config.App.IsDebug {
opts.IsHeadless = false
}
browser, err := core.NewBrowser(opts)
if err != nil {
logrus.Error(err)
return
}
yand := yandex.New(*browser, config.YandexConfig)
gogl := google.New(*browser, config.GoogleConfig)
baidu := baidu.New(*browser, config.BaiduConfig)
bing := bing.New(*browser, config.BingConfig)
ddg := duckduckgo.New(*browser, config.DuckDuckGoConfig)
serv := core.NewServerWithOptions(config.App.Host, config.App.Port, serverOpts, gogl, yand, baidu, bing, ddg)
err = serv.Listen()
if err != nil {
logrus.Error(err)
for _, entryTag := range entry.Tags {
if strings.TrimSpace(strings.ToLower(entryTag)) == tag {
return true
}
}
return false
}
func init() {

105
cmd/serve_test.go Normal file
View File

@@ -0,0 +1,105 @@
package cmd
import (
"strings"
"testing"
"github.com/karust/openserp/core"
)
func TestValidateBrowserProxyPolicyRejectsAuthenticatedSocks(t *testing.T) {
tests := []struct {
name string
proxyCfg core.ProxyConfig
policy core.ProxyPolicy
}{
{
name: "global authenticated socks",
proxyCfg: core.ProxyConfig{
Proxies: core.ProxiesConfig{
Global: "socks5h://user:pass@127.0.0.1:1080",
},
},
policy: core.ProxyPolicy{Mode: core.ProxyModeTagPool},
},
{
name: "tag pool authenticated socks",
proxyCfg: core.ProxyConfig{
Proxies: core.ProxiesConfig{
Entries: []core.ProxyEntryConfig{
{URL: "socks5://user:pass@127.0.0.1:1080", Tags: []string{"us"}},
},
},
},
policy: core.ProxyPolicy{Mode: core.ProxyModeTagPool, Tag: "us"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBrowserProxyPolicy(tt.proxyCfg, tt.policy)
if err == nil {
t.Fatal("expected browser proxy validation to fail")
}
if !strings.Contains(err.Error(), "authenticated SOCKS proxy") {
t.Fatalf("expected explicit authenticated SOCKS error, got %v", err)
}
})
}
}
func TestValidateBrowserProxyPolicyAllowsHTTPAuthAndPlainSocks(t *testing.T) {
tests := []struct {
name string
proxyCfg core.ProxyConfig
policy core.ProxyPolicy
}{
{
name: "global http auth",
proxyCfg: core.ProxyConfig{
Proxies: core.ProxiesConfig{
Global: "http://user:pass@127.0.0.1:8080",
},
},
policy: core.ProxyPolicy{Mode: core.ProxyModeTagPool},
},
{
name: "tag pool plain socks",
proxyCfg: core.ProxyConfig{
Proxies: core.ProxiesConfig{
Entries: []core.ProxyEntryConfig{
{URL: "socks5://127.0.0.1:1080", Tags: []string{"eu"}},
},
},
},
policy: core.ProxyPolicy{Mode: core.ProxyModeTagPool, Tag: "eu"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validateBrowserProxyPolicy(tt.proxyCfg, tt.policy); err != nil {
t.Fatalf("expected browser proxy validation to succeed, got %v", err)
}
})
}
}
func TestValidateBrowserProxyPolicyRejectsTaggedAuthenticatedSocksInPool(t *testing.T) {
proxyCfg := core.ProxyConfig{
Proxies: core.ProxiesConfig{
Entries: []core.ProxyEntryConfig{
{URL: "http://127.0.0.1:8080", Tags: []string{"default"}},
{URL: "socks5://user:pass@127.0.0.1:1080", Tags: []string{"default"}},
},
},
}
err := validateBrowserProxyPolicy(proxyCfg, core.ProxyPolicy{Mode: core.ProxyModeTagPool, Tag: "default"})
if err == nil {
t.Fatal("expected browser proxy validation to fail for tag pool")
}
if !strings.Contains(err.Error(), "authenticated SOCKS proxy") {
t.Fatalf("expected explicit authenticated SOCKS error, got %v", err)
}
}