mirror of
https://github.com/karust/openserp.git
synced 2026-08-18 22:28:21 +08:00
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:
30
cmd/proxy_policy.go
Normal file
30
cmd/proxy_policy.go
Normal 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])
|
||||
}
|
||||
184
cmd/root.go
184
cmd/root.go
@@ -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)")
|
||||
|
||||
102
cmd/search.go
102
cmd/search.go
@@ -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() {
|
||||
|
||||
320
cmd/serve.go
320
cmd/serve.go
@@ -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
105
cmd/serve_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user