http_loader 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/http_loader/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'http_loader'
7
+ spec.version = HttpLoader::VERSION
8
+ spec.authors = ['Vitalii Lazebnyi']
9
+ spec.email = ['vitalii.lazebnyi.github@gmail.com']
10
+
11
+ spec.summary = 'Keep-Alive High Concurrency Load Testing Framework'
12
+ spec.description = 'A performance testing tool for HTTP/HTTPS.'
13
+ spec.homepage = 'https://github.com/VitaliiLazebnyi/http_loader'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 4.0'
16
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
17
+ spec.metadata['source_code_uri'] = 'https://github.com/VitaliiLazebnyi/http_loader'
18
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/VitaliiLazebnyi/http_loader/issues'
19
+ spec.metadata['changelog_uri'] = 'https://github.com/VitaliiLazebnyi/http_loader/blob/main/CHANGELOG.md'
20
+ spec.metadata['rubygems_mfa_required'] = 'true'
21
+
22
+ spec.cert_chain = ['certs/http_loader-public_cert.pem']
23
+ spec.signing_key = File.expand_path('~/.gem/gem-private_key.pem') if $PROGRAM_NAME.end_with?('gem') && File.exist?(File.expand_path('~/.gem/gem-private_key.pem'))
24
+
25
+ spec.files = %w[
26
+ BUGS.md
27
+ Gemfile
28
+ LICENSE.txt
29
+ PERFORMANCE_REPORT.md
30
+ README.md
31
+ REQUIREMENTS.md
32
+ http_loader.gemspec
33
+ ] + Dir.glob('{lib,bin,certs}/**/*', base: __dir__).select do |f|
34
+ File.file?(File.expand_path(f, __dir__))
35
+ end
36
+ spec.bindir = 'bin'
37
+ spec.executables = ['http_loader']
38
+ spec.require_paths = ['lib']
39
+
40
+ spec.add_dependency 'async', '~> 2.39'
41
+ spec.add_dependency 'async-http', '~> 0.95'
42
+ spec.add_dependency 'falcon', '~> 0.55'
43
+ spec.add_dependency 'rack', '~> 3.2'
44
+ spec.add_dependency 'rackup', '~> 2.3'
45
+ spec.add_dependency 'sorbet-runtime', '~> 0.6'
46
+
47
+ spec.add_development_dependency 'memory_profiler', '~> 1.1'
48
+ spec.add_development_dependency 'rake', '~> 13.3'
49
+ spec.add_development_dependency 'rspec', '~> 3.13'
50
+ spec.add_development_dependency 'rubocop', '~> 1.86'
51
+ spec.add_development_dependency 'rubocop-md', '~> 2.0'
52
+ spec.add_development_dependency 'rubocop-performance', '~> 1.26'
53
+ spec.add_development_dependency 'rubocop-rake', '~> 0.7'
54
+ spec.add_development_dependency 'rubocop-rspec', '~> 3.9'
55
+ spec.add_development_dependency 'rubocop-thread_safety', '~> 0.7'
56
+ spec.add_development_dependency 'ruby-prof', '~> 2.0'
57
+ spec.add_development_dependency 'simplecov', '~> 0.22'
58
+ spec.add_development_dependency 'sorbet', '~> 0.6'
59
+ spec.add_development_dependency 'yard', '~> 0.9'
60
+ spec.add_development_dependency 'yard-sorbet', '~> 0.8'
61
+ end
@@ -0,0 +1,120 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module HttpLoader
7
+ # Extracted arguments parsers for strict metric compliance.
8
+ module CliArgs
9
+ # ClientParser configures OptionParser mapping specifically for Client configurations
10
+ class ClientParser
11
+ def self.parse(opts, options)
12
+ parse_core(opts, options)
13
+ parse_ping(opts, options)
14
+ parse_timeouts(opts, options)
15
+ end
16
+
17
+ def self.parse_core(opts, options)
18
+ opts.on('--connections_count=COUNT', Integer, 'Total') { |v| options[:connections] = v }
19
+ opts.on('--https', 'Use HTTPS natively') { options[:use_https] = true }
20
+ opts.on('--url=URL', String, 'URLs') { |v| options[:target_urls] = v.split(',') }
21
+ opts.on('--verbose', 'Verbose logging') { options[:verbose] = true }
22
+ end
23
+
24
+ def self.parse_ping(opts, options)
25
+ opts.on('--[no-]ping', 'Ping') { |v| options[:ping] = v }
26
+ opts.on('--ping_period=SECONDS', Integer, 'Ping period') { |v| options[:ping_period] = v }
27
+ end
28
+
29
+ def self.parse_timeouts(opts, options)
30
+ opts.on('--http_loader_timeout=S', Float, 'Keep') { |v| options[:http_loader_timeout] = v }
31
+ opts.on('--connections_per_second=R', Integer, 'Rate') { |v| options[:connections_per_second] = v }
32
+ opts.on('--max_concurrent_connections=C', Integer, 'Max') { |v| options[:max_concurrent_connections] = v }
33
+ parse_advanced(opts, options)
34
+ end
35
+
36
+ def self.parse_advanced(opts, options)
37
+ opts.on('--reopen_closed_connections', 'Reopen') { options[:reopen_closed_connections] = true }
38
+ opts.on('--reopen_interval=S', Float, 'Reopen delay') { |v| options[:reopen_interval] = v }
39
+ opts.on('--read_timeout=S', Float, 'Read timeout') { |v| options[:read_timeout] = v }
40
+ parse_tracking(opts, options)
41
+ end
42
+
43
+ def self.parse_tracking(opts, options)
44
+ opts.on('--user_agent=A', String, 'User Agent') { |v| options[:user_agent] = v }
45
+ opts.on('--jitter=F', Float, 'Randomize sleep') { |v| options[:jitter] = v }
46
+ opts.on('--track_status_codes', 'Track HTTP codes') { options[:track_status_codes] = true }
47
+ parse_endpoints(opts, options)
48
+ end
49
+
50
+ def self.parse_endpoints(opts, options)
51
+ opts.on('--ramp_up=S', Float, 'Smoothly scale') { |val| options[:ramp_up] = val }
52
+ opts.on('--bind_ips=IPS', String, 'IPs') { |val| options[:bind_ips] = val.split(',') }
53
+ opts.on('--proxy_pool=U', String, 'URI pool') { |val| options[:proxy_pool] = val.split(',') }
54
+ parse_slowloris(opts, options)
55
+ end
56
+
57
+ def self.parse_slowloris(opts, options)
58
+ opts.on('--qps_per_connection=R', Integer, 'Active QPS') { |val| options[:qps_per_connection] = val }
59
+ opts.on('--headers=LIST', String, 'Headers') do |val|
60
+ val.split(',').each do |pair|
61
+ key, value = pair.split(':', 2)
62
+ options[:headers][key.strip] = value.strip if key && value
63
+ end
64
+ end
65
+ parse_slowloris_delays(opts, options)
66
+ end
67
+
68
+ def self.parse_slowloris_delays(opts, options)
69
+ opts.on('--slowloris_delay=S', Float, 'Gap') { |v| options[:slowloris_delay] = v }
70
+ opts.on('--export_json=FILE', String) { nil }
71
+ opts.on('--target_duration=S', Float) { nil }
72
+ end
73
+ end
74
+
75
+ # HarnessParser strictly parses orchestrator arguments ignoring explicitly mapped client arguments dynamically.
76
+ class HarnessParser
77
+ def self.parse(opts, options)
78
+ opts.on('--connections_count=C', Integer) { |v| options[:connections] = v }
79
+ opts.on('--https') { options[:use_https] = true }
80
+ opts.on('--url=URL', String) { |v| options[:target_urls] = v.split(',') }
81
+ opts.on('--export_json=FILE', String) { |v| options[:export_json] = v }
82
+ opts.on('--target_duration=S', Float) { |v| options[:target_duration] = v }
83
+ ignore_core_args(opts)
84
+ end
85
+
86
+ def self.ignore_core_args(opts)
87
+ opts.on('--verbose') { nil }
88
+ opts.on('--[no-]ping') { nil }
89
+ opts.on('--ping_period=S', Integer) { nil }
90
+ opts.on('--http_loader_timeout=S', Float) { nil }
91
+ opts.on('--connections_per_second=R', Integer) { nil }
92
+ ignore_time_args(opts)
93
+ end
94
+
95
+ def self.ignore_time_args(opts)
96
+ opts.on('--max_concurrent_connections=C', Integer) { nil }
97
+ opts.on('--reopen_closed_connections') { nil }
98
+ opts.on('--reopen_interval=S', Float) { nil }
99
+ opts.on('--read_timeout=S', Float) { nil }
100
+ opts.on('--user_agent=A', String) { nil }
101
+ ignore_advanced_args(opts)
102
+ end
103
+
104
+ def self.ignore_advanced_args(opts)
105
+ opts.on('--jitter=F', Float) { nil }
106
+ opts.on('--track_status_codes') { nil }
107
+ opts.on('--ramp_up=S', Float) { nil }
108
+ opts.on('--bind_ips=IPS', String) { nil }
109
+ opts.on('--proxy_pool=U', String) { nil }
110
+ ignore_payload_args(opts)
111
+ end
112
+
113
+ def self.ignore_payload_args(opts)
114
+ opts.on('--qps_per_connection=R', Integer) { nil }
115
+ opts.on('--headers=LIST', String) { nil }
116
+ opts.on('--slowloris_delay=S', Float) { nil }
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,33 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module HttpLoader
7
+ class Client
8
+ # Config parameters for Client connections.
9
+ class Config < T::Struct
10
+ const :connections, Integer
11
+ const :target_urls, T::Array[String], default: []
12
+ const :use_https, T::Boolean, default: false
13
+ const :verbose, T::Boolean, default: false
14
+ const :ping, T::Boolean, default: true
15
+ const :ping_period, Integer, default: 5
16
+ const :http_loader_timeout, Float, default: 0.0
17
+ const :connections_per_second, Integer, default: 0
18
+ const :max_concurrent_connections, Integer, default: 1000
19
+ const :reopen_closed_connections, T::Boolean, default: false
20
+ const :reopen_interval, Float, default: 5.0
21
+ const :read_timeout, Float, default: 0.0
22
+ const :user_agent, String, default: 'Keep-Alive Test'
23
+ const :jitter, Float, default: 1.0
24
+ const :track_status_codes, T::Boolean, default: false
25
+ const :ramp_up, Float, default: 0.0
26
+ const :bind_ips, T::Array[String], default: []
27
+ const :proxy_pool, T::Array[String], default: []
28
+ const :qps_per_connection, Integer, default: 0
29
+ const :headers, T::Hash[String, String], default: {}
30
+ const :slowloris_delay, Float, default: 0.0
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module HttpLoader
7
+ class Client
8
+ # ErrorHandler provides error handling strategies natively.
9
+ module ErrorHandler
10
+ extend T::Sig
11
+ extend T::Helpers
12
+
13
+ requires_ancestor { HttpLoader::Client }
14
+
15
+ sig { params(idx: Integer, err: StandardError).void }
16
+ def handle_err(idx, err)
17
+ case err
18
+ when Errno::EMFILE
19
+ @logger.error("[Client #{idx}] ERROR_EMFILE: #{err.message}")
20
+ when Errno::EADDRNOTAVAIL
21
+ @logger.error("[Client #{idx}] ERROR_EADDRNOTAVAIL: Ephemeral port limit reached.")
22
+ else
23
+ @logger.error("[Client #{idx}] ERROR_OTHER: #{err.message}")
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,118 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+ require 'net/http'
6
+
7
+ module HttpLoader
8
+ class Client
9
+ # Handles normal and QPS-driven HTTP keep-alive connections.
10
+ class HttpSession
11
+ extend T::Sig
12
+
13
+ sig { params(config: Config, logger: Logger).void }
14
+ def initialize(config, logger)
15
+ @config = config
16
+ @logger = logger
17
+ end
18
+
19
+ sig { params(client_index: Integer, uri: URI::Generic, http: Net::HTTP, start_time: Time).void }
20
+ def run(client_index, uri, http, start_time)
21
+ return if @config.slowloris_delay.positive?
22
+
23
+ fire_initial_request(uri, http)
24
+ maintain_keepalive(client_index, uri, http, start_time)
25
+ end
26
+
27
+ private
28
+
29
+ sig { params(uri: URI::Generic, http: Net::HTTP).void }
30
+ def fire_initial_request(uri, http)
31
+ request = Net::HTTP::Get.new(uri)
32
+ request['Connection'] = 'keep-alive'
33
+ request['User-Agent'] = @config.user_agent
34
+ @config.headers.each { |k, v| request[k] = v }
35
+
36
+ http.request(request) do |response|
37
+ response.read_body { |_chunk| nil }
38
+ end
39
+ end
40
+
41
+ sig { params(client_index: Integer, uri: URI::Generic, http: Net::HTTP, start_time: Time).void }
42
+ def maintain_keepalive(client_index, uri, http, start_time)
43
+ loop do
44
+ elapsed = Time.now - start_time
45
+ if @config.http_loader_timeout.positive? && elapsed >= @config.http_loader_timeout
46
+ @logger.info("[Client #{client_index}] Keep-alive timeout reached, closing.")
47
+ break
48
+ end
49
+
50
+ break unless process_heartbeat?(client_index, uri, http)
51
+ end
52
+ end
53
+
54
+ sig { params(client_index: Integer, uri: URI::Generic, http: Net::HTTP).returns(T::Boolean) }
55
+ def process_heartbeat?(client_index, uri, http)
56
+ if @config.qps_per_connection.positive?
57
+ perform_qps?(client_index, uri, http)
58
+ elsif @config.ping
59
+ perform_ping?(client_index, uri, http)
60
+ else
61
+ sleep(calculate_sleep(1.0))
62
+ true
63
+ end
64
+ end
65
+
66
+ sig { params(client_index: Integer, uri: URI::Generic, http: Net::HTTP).returns(T::Boolean) }
67
+ def perform_qps?(client_index, uri, http)
68
+ sleep(calculate_sleep(1.0 / @config.qps_per_connection))
69
+ req = build_request(Net::HTTP::Get.new(uri))
70
+
71
+ res = http.request(req) do |r|
72
+ r.read_body { |_c| nil }
73
+ end
74
+ log_status(client_index, res)
75
+ success?(res)
76
+ end
77
+
78
+ sig { params(client_index: Integer, uri: URI::Generic, http: Net::HTTP).returns(T::Boolean) }
79
+ def perform_ping?(client_index, uri, http)
80
+ sleep(calculate_sleep(@config.ping_period.to_f))
81
+ req = build_request(Net::HTTP::Head.new(uri))
82
+
83
+ res = http.request(req)
84
+ log_status(client_index, res)
85
+ success?(res)
86
+ end
87
+
88
+ sig { params(req: Net::HTTPRequest).returns(Net::HTTPRequest) }
89
+ def build_request(req)
90
+ req['Connection'] = 'keep-alive'
91
+ req['User-Agent'] = @config.user_agent
92
+ @config.headers.each { |k, v| req[k] = v }
93
+ req
94
+ end
95
+
96
+ sig { params(res: Net::HTTPResponse).returns(T::Boolean) }
97
+ def success?(res)
98
+ res.is_a?(Net::HTTPSuccess) || res.is_a?(Net::HTTPRedirection)
99
+ end
100
+
101
+ sig { params(client_index: Integer, res: Net::HTTPResponse).void }
102
+ def log_status(client_index, res)
103
+ return unless @config.track_status_codes
104
+ return if success?(res)
105
+
106
+ @logger.info("[Client #{client_index}] Upstream returned HTTP #{res.code}")
107
+ end
108
+
109
+ sig { params(base_seconds: Float).returns(Float) }
110
+ def calculate_sleep(base_seconds)
111
+ return base_seconds if @config.jitter.zero?
112
+
113
+ variance = base_seconds * @config.jitter
114
+ [0.0, base_seconds + rand(-variance..variance)].max
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,111 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+ require 'fileutils'
6
+ require 'time'
7
+
8
+ module HttpLoader
9
+ class Client
10
+ # Handles asynchronous file logging to prevent blocking main connections.
11
+ class Logger
12
+ extend T::Sig
13
+
14
+ sig { params(verbose: T::Boolean).void }
15
+ def initialize(verbose)
16
+ @verbose = verbose
17
+ @log_dir = T.let(File.expand_path('../../../logs', __dir__), String)
18
+ @log_queue = T.let(Queue.new, Queue)
19
+ @logger_task = T.let(nil, T.nilable(T.untyped))
20
+ end
21
+
22
+ sig { void }
23
+ def setup_files!
24
+ FileUtils.mkdir_p(@log_dir)
25
+ File.write(File.join(@log_dir, 'client.err'), '')
26
+ File.write(File.join(@log_dir, 'client.log'), '') if @verbose
27
+ end
28
+
29
+ sig { params(task: T.untyped).returns(T.untyped) }
30
+ def run_task(task)
31
+ @logger_task = task.async do
32
+ File.open(File.join(@log_dir, 'client.log'), 'a') do |log|
33
+ File.open(File.join(@log_dir, 'client.err'), 'a') do |err|
34
+ poll_queue(task, log, err)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ sig { void }
41
+ def flush_synchronously!
42
+ File.open(File.join(@log_dir, 'client.log'), 'a') do |log|
43
+ File.open(File.join(@log_dir, 'client.err'), 'a') do |err|
44
+ drain_queue(log, err)
45
+ end
46
+ end
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ sig { params(message: String).void }
52
+ def info(message)
53
+ return unless @verbose
54
+
55
+ @log_queue << [:info, "[#{Time.now.utc.iso8601}] #{message}"]
56
+ end
57
+
58
+ sig { params(message: String).void }
59
+ def error(message)
60
+ @log_queue << [:error, "[#{Time.now.utc.iso8601}] #{message}"]
61
+ end
62
+
63
+ private
64
+
65
+ sig { params(task: T.untyped, log: File, err: File).void }
66
+ def poll_queue(task, log, err)
67
+ loop do
68
+ msg = fetch_message(task)
69
+ next unless msg
70
+ break if msg == :terminate
71
+
72
+ write_msg(msg, log, err)
73
+ end
74
+ end
75
+
76
+ sig { params(log: File, err: File).void }
77
+ def drain_queue(log, err)
78
+ loop do
79
+ msg = begin
80
+ @log_queue.pop(true)
81
+ rescue ThreadError
82
+ nil
83
+ end
84
+ break unless msg && msg != :terminate
85
+
86
+ write_msg(msg, log, err)
87
+ end
88
+ end
89
+
90
+ sig { params(task: T.untyped).returns(T.untyped) }
91
+ def fetch_message(task)
92
+ @log_queue.pop(true)
93
+ rescue ThreadError
94
+ task.sleep(0.05)
95
+ nil
96
+ end
97
+
98
+ sig { params(msg: T::Array[T.untyped], log: File, err: File).void }
99
+ def write_msg(msg, log, err)
100
+ target, content = msg
101
+ if target == :info
102
+ log.puts content
103
+ log.flush
104
+ elsif target == :error
105
+ err.puts content
106
+ err.flush
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,81 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module HttpLoader
7
+ class Client
8
+ # Handles deliberate thread-hogging Slowloris tactics.
9
+ class Slowloris
10
+ extend T::Sig
11
+
12
+ sig { params(config: Config, logger: Logger).void }
13
+ def initialize(config, logger)
14
+ @config = config
15
+ @logger = logger
16
+ end
17
+
18
+ sig { params(client_index: Integer, uri: URI::Generic, http: Net::HTTP, start_time: Time).void }
19
+ def run(client_index, uri, http, start_time)
20
+ return unless @config.slowloris_delay.positive?
21
+
22
+ # We must use T.unsafe because @socket is a protected state variable natively
23
+ socket_wrapper = T.unsafe(http).instance_variable_get(:@socket)
24
+ return unless socket_wrapper
25
+
26
+ io = socket_wrapper.io
27
+ fire_initial_payload(io, uri)
28
+ maintain_hold(client_index, io, start_time)
29
+ end
30
+
31
+ private
32
+
33
+ sig { params(io: IO, uri: URI::Generic).void }
34
+ def fire_initial_payload(io, uri)
35
+ payload = build_payload_headers(uri)
36
+
37
+ payload.each_char do |char|
38
+ io.write(char)
39
+ io.flush
40
+ sleep(calculate_sleep(@config.slowloris_delay))
41
+ end
42
+ end
43
+
44
+ sig { params(uri: URI::Generic).returns(String) }
45
+ def build_payload_headers(uri)
46
+ path = uri.path.empty? ? '/' : uri.path
47
+ query = uri.query ? "?#{uri.query}" : ''
48
+ payload = "GET #{path}#{query} HTTP/1.1\r\n" \
49
+ "Host: #{uri.host}\r\n" \
50
+ "Connection: keep-alive\r\n" \
51
+ "User-Agent: #{@config.user_agent}\r\n"
52
+ @config.headers.each { |k, v| payload += "#{k}: #{v}\r\n" }
53
+ payload += 'X-Slowloris: ' # unfinished header
54
+ payload
55
+ end
56
+
57
+ sig { params(client_index: Integer, io: IO, start_time: Time).void }
58
+ def maintain_hold(client_index, io, start_time)
59
+ loop do
60
+ elapsed = Time.now - start_time
61
+ if @config.http_loader_timeout.positive? && elapsed >= @config.http_loader_timeout
62
+ @logger.info("[Client #{client_index}] Keep-alive timeout reached, closing Slowloris thread.")
63
+ break
64
+ end
65
+
66
+ io.write(rand(97..122).chr)
67
+ io.flush
68
+ sleep(calculate_sleep(@config.slowloris_delay))
69
+ end
70
+ end
71
+
72
+ sig { params(base_seconds: Float).returns(Float) }
73
+ def calculate_sleep(base_seconds)
74
+ return base_seconds if @config.jitter.zero?
75
+
76
+ variance = base_seconds * @config.jitter
77
+ [0.0, base_seconds + rand(-variance..variance)].max
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,99 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+ require 'uri'
6
+ require 'socket'
7
+
8
+ module HttpLoader
9
+ class Client
10
+ # Manages URI contexts, IPs, proxies, and HTTPS resolution.
11
+ class TargetManager
12
+ extend T::Sig
13
+
14
+ sig { params(config: Config).void }
15
+ def initialize(config)
16
+ @config = config
17
+ @target_contexts = T.let(build_target_contexts, T::Array[T::Hash[Symbol, T.untyped]])
18
+ end
19
+
20
+ sig { returns(String) }
21
+ def protocol_label
22
+ if @config.target_urls.size > 1
23
+ "MULTIPLE TARGETS (#{@config.target_urls.size})"
24
+ elsif @config.target_urls.size == 1
25
+ "EXTERNAL #{T.cast(T.must(@target_contexts.first)[:uri], URI::Generic).scheme&.upcase}"
26
+ elsif @config.use_https
27
+ 'HTTPS'
28
+ else
29
+ 'HTTP'
30
+ end
31
+ end
32
+
33
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
34
+ def contexts
35
+ @target_contexts
36
+ end
37
+
38
+ sig { params(client_index: Integer).returns(T::Hash[Symbol, T.untyped]) }
39
+ def context_for(client_index)
40
+ T.must(@target_contexts[client_index % @target_contexts.size])
41
+ end
42
+
43
+ sig { params(client_index: Integer, args: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
44
+ def http_opts_for(client_index, args)
45
+ http_opts = args.dup
46
+ bind_ips = @config.bind_ips
47
+ http_opts[:local_host] = bind_ips[client_index % bind_ips.size] if bind_ips.any?
48
+
49
+ apply_proxy!(http_opts, client_index) if @config.proxy_pool.any?
50
+ http_opts
51
+ end
52
+
53
+ sig { params(opts: T::Hash[Symbol, T.untyped], client_index: Integer).void }
54
+ def apply_proxy!(opts, client_index)
55
+ pool = @config.proxy_pool
56
+ proxy_uri = URI.parse(pool[client_index % pool.size])
57
+ opts[:proxy_address] = proxy_uri.host
58
+ opts[:proxy_port] = proxy_uri.port
59
+ opts[:proxy_user] = proxy_uri.user if proxy_uri.user
60
+ opts[:proxy_pass] = proxy_uri.password if proxy_uri.password
61
+ end
62
+
63
+ private
64
+
65
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
66
+ def build_target_contexts
67
+ urls = @config.target_urls.any? ? @config.target_urls : [nil]
68
+ urls.map do |url|
69
+ uri = parse_uri(url)
70
+ args = { read_timeout: @config.read_timeout.positive? ? @config.read_timeout : nil }
71
+ args[:ipaddr] = resolve_ip(uri)
72
+ { uri: uri, http_args: secure_opts(uri, args) }
73
+ end
74
+ end
75
+
76
+ sig { params(url: T.nilable(String)).returns(URI::Generic) }
77
+ def parse_uri(url)
78
+ return URI(url.to_s) if url
79
+
80
+ @config.use_https ? URI('https://localhost:8443') : URI('http://localhost:8080')
81
+ end
82
+
83
+ sig { params(uri: URI::Generic).returns(T.nilable(String)) }
84
+ def resolve_ip(uri)
85
+ ip_info = Addrinfo.getaddrinfo(T.must(uri.host), uri.port, nil, :STREAM)
86
+ (ip_info.find(&:ipv4?) || ip_info.first)&.ip_address
87
+ rescue SocketError
88
+ nil
89
+ end
90
+
91
+ sig { params(uri: URI::Generic, args: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
92
+ def secure_opts(uri, args)
93
+ return args unless uri.scheme == 'https'
94
+
95
+ args.merge(use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_NONE)
96
+ end
97
+ end
98
+ end
99
+ end