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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +1 -0
- data/BUGS.md +5 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/PERFORMANCE_REPORT.md +184 -0
- data/README.md +262 -0
- data/REQUIREMENTS.md +41 -0
- data/bin/http_loader +80 -0
- data/certs/http_loader-public_cert.pem +25 -0
- data/http_loader.gemspec +61 -0
- data/lib/http_loader/cli_args.rb +120 -0
- data/lib/http_loader/client/config.rb +33 -0
- data/lib/http_loader/client/error_handler.rb +28 -0
- data/lib/http_loader/client/http_session.rb +118 -0
- data/lib/http_loader/client/logger.rb +111 -0
- data/lib/http_loader/client/slowloris.rb +81 -0
- data/lib/http_loader/client/target_manager.rb +99 -0
- data/lib/http_loader/client.rb +135 -0
- data/lib/http_loader/harness/config.rb +18 -0
- data/lib/http_loader/harness/formatter.rb +82 -0
- data/lib/http_loader/harness/process_manager.rb +81 -0
- data/lib/http_loader/harness/resource_monitor.rb +132 -0
- data/lib/http_loader/harness/telemetry.rb +76 -0
- data/lib/http_loader/harness.rb +110 -0
- data/lib/http_loader/server.rb +117 -0
- data/lib/http_loader/version.rb +6 -0
- data/lib/http_loader.rb +12 -0
- data/lib/rubocop/cop/ai/adverb_spam.rb +46 -0
- data.tar.gz.sig +0 -0
- metadata +379 -0
- metadata.gz.sig +0 -0
data/http_loader.gemspec
ADDED
|
@@ -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
|