ruby-proxy-headers 0.2.2 → 0.3.0

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.
data/docs/index.md ADDED
@@ -0,0 +1,109 @@
1
+ # ruby-proxy-headers
2
+
3
+ Send and receive custom proxy headers during HTTPS CONNECT tunneling in Ruby.
4
+
5
+ ## The Problem
6
+
7
+ When making HTTPS requests through a proxy server, a tunnel is established using the HTTP CONNECT method:
8
+
9
+ ```
10
+ Client → CONNECT example.com:443 HTTP/1.1 → Proxy → Target
11
+ ← 200 Connection established ←
12
+ ←→ [TLS encrypted traffic] ←→
13
+ ```
14
+
15
+ Standard Ruby HTTP libraries support basic proxy authentication but **do not** allow:
16
+
17
+ - Sending additional custom headers during CONNECT (e.g., `X-ProxyMesh-Country`)
18
+ - Accessing headers returned by the proxy (e.g., `X-ProxyMesh-IP`)
19
+
20
+ **ruby-proxy-headers** solves both problems.
21
+
22
+ ## Features
23
+
24
+ - **Send custom headers** to the proxy during HTTPS CONNECT
25
+ - **Receive proxy response headers** from the CONNECT response
26
+ - **Support for 7 popular Ruby HTTP libraries**
27
+ - **Thread-safe** implementation
28
+ - **Pure Ruby** core (no native extensions required)
29
+
30
+ ## Supported Libraries
31
+
32
+ | Library | Module | Status |
33
+ |---------|--------|--------|
34
+ | Net::HTTP | `RubyProxyHeaders::NetHTTP` | ✅ |
35
+ | Faraday | `RubyProxyHeaders::FaradayIntegration` | ✅ |
36
+ | HTTParty | `RubyProxyHeaders::ProxyHeadersConnectionAdapter` | ✅ |
37
+ | HTTP.rb | `RubyProxyHeaders::HTTPGem` | ✅, http gem >= 6 |
38
+ | Typhoeus | `RubyProxyHeaders::Typhoeus` | ✅, Typhoeus ~> 1.7 |
39
+ | Excon | `RubyProxyHeaders::ExconIntegration` | Send only |
40
+ | RestClient | `RubyProxyHeaders::RestClient` | ✅ |
41
+
42
+ ## Quick Start
43
+
44
+ ```ruby
45
+ require 'uri'
46
+ require 'ruby_proxy_headers/net_http'
47
+
48
+ RubyProxyHeaders::NetHTTP.patch!
49
+
50
+ uri = URI('https://api.ipify.org?format=json')
51
+ proxy = URI('http://user:pass@proxy.example.com:31280')
52
+ http = Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, proxy.user, proxy.password)
53
+ http.use_ssl = true
54
+ http.proxy_connect_request_headers = { 'X-ProxyMesh-Country' => 'US' }
55
+ response = http.request(Net::HTTP::Get.new(uri))
56
+
57
+ puts response.code
58
+ puts response.body
59
+ puts http.last_proxy_connect_response_headers['X-ProxyMesh-IP']
60
+ ```
61
+
62
+ ## Use Cases
63
+
64
+ ### ProxyMesh Integration
65
+
66
+ [ProxyMesh](https://docs.proxymesh.com/) uses custom headers for:
67
+
68
+ - **Country selection**: `X-ProxyMesh-Country: US`
69
+ - **Session persistence**: `X-ProxyMesh-Session: abc123`
70
+ - **IP assignment feedback**: Returns `X-ProxyMesh-IP` in response
71
+
72
+ ### Proxy Service Headers
73
+
74
+ Many proxy services use custom headers for:
75
+
76
+ - Geolocation targeting
77
+ - Session management
78
+ - Usage tracking
79
+ - Load balancing hints
80
+
81
+ ## Installation
82
+
83
+ Add to your Gemfile:
84
+
85
+ ```ruby
86
+ gem 'ruby-proxy-headers'
87
+ ```
88
+
89
+ Then run:
90
+
91
+ ```bash
92
+ bundle install
93
+ ```
94
+
95
+ Or install directly:
96
+
97
+ ```bash
98
+ gem install ruby_proxy_headers
99
+ ```
100
+
101
+ ## Requirements
102
+
103
+ - Ruby >= 3.2
104
+ - OpenSSL (included in standard Ruby)
105
+
106
+ ## Related Projects
107
+
108
+ - [python-proxy-headers](https://github.com/proxymesh/python-proxy-headers) - Python version
109
+ - [javascript-proxy-headers](https://github.com/proxymeshai/javascript-proxy-headers) - JavaScript/Node.js version
data/docs/net-http.md ADDED
@@ -0,0 +1,29 @@
1
+ # Net::HTTP
2
+
3
+ `RubyProxyHeaders::NetHTTP.patch!` prepends a small wrapper around the installed `Net::HTTP#connect`. The gem does not replace that method with its own copy. Call `patch!` once before opening connections.
4
+
5
+ Proxy header names are documented at <https://docs.proxymesh.com/>.
6
+
7
+ ## Usage
8
+
9
+ ```ruby
10
+ require 'uri'
11
+ require 'ruby_proxy_headers/net_http'
12
+
13
+ RubyProxyHeaders::NetHTTP.patch!
14
+
15
+ uri = URI('https://api.ipify.org?format=json')
16
+ proxy = URI('http://user:pass@proxy.example.com:31280')
17
+
18
+ http = Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, proxy.user, proxy.password)
19
+ http.use_ssl = true
20
+ http.proxy_connect_request_headers = { 'X-ProxyMesh-Country' => 'US' }
21
+
22
+ response = http.request(Net::HTTP::Get.new(uri))
23
+ puts response.body
24
+ puts http.last_proxy_connect_response_headers['X-ProxyMesh-IP']
25
+ ```
26
+
27
+ `last_proxy_connect_response_headers` is case-insensitive. The same headers are on `RubyProxyHeaders.proxy_connect_response_headers` for the current thread.
28
+
29
+ An `https://` proxy needs net-http 0.5 or newer, which is what `proxy_use_ssl` requires. Set `http.proxy_use_ssl = true` for that proxy.
@@ -0,0 +1,96 @@
1
+ # RestClient
2
+
3
+ [RestClient](https://github.com/rest-client/rest-client) is a simple REST client. The `RubyProxyHeaders::RestClient` module adds proxy header support.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem 'rest-client'
9
+ gem 'ruby-proxy-headers'
10
+ ```
11
+
12
+ ## Basic Usage
13
+
14
+ ```ruby
15
+ require 'ruby_proxy_headers/rest_client'
16
+
17
+ response = RubyProxyHeaders::RestClient.get(
18
+ 'https://api.ipify.org?format=json',
19
+ proxy: 'http://user:pass@proxy.example.com:31280',
20
+ proxy_headers: { 'X-ProxyMesh-Country' => 'US' }
21
+ )
22
+
23
+ puts "Status: #{response.code}"
24
+ puts "Body: #{response.body}"
25
+ puts "Proxy IP: #{response.proxy_response_headers['x-proxymesh-ip']}"
26
+ ```
27
+
28
+ ## API Reference
29
+
30
+ ### `RubyProxyHeaders::RestClient.get(url, options)`
31
+
32
+ Make a GET request.
33
+
34
+ **Parameters:**
35
+
36
+ - `url` (String) - Target URL
37
+ - `options` (Hash):
38
+ - `:proxy` (String) - Proxy URL (required)
39
+ - `:proxy_headers` (Hash) - Headers to send to proxy
40
+ - `:headers` (Hash) - Request headers
41
+
42
+ **Returns:** `ProxyResponse`
43
+
44
+ ### `RubyProxyHeaders::RestClient.post(url, options)`
45
+
46
+ Make a POST request.
47
+
48
+ **Parameters:**
49
+
50
+ - Same as `get`, plus:
51
+ - `:payload` (String/Hash) - Request body
52
+
53
+ ### `RubyProxyHeaders::RestClient.put(url, options)`
54
+
55
+ Make a PUT request.
56
+
57
+ ### `RubyProxyHeaders::RestClient.delete(url, options)`
58
+
59
+ Make a DELETE request.
60
+
61
+ ## Response Object
62
+
63
+ ```ruby
64
+ response.code # HTTP status code
65
+ response.body # Response body
66
+ response.headers # Response headers
67
+ response.proxy_response_headers # Proxy CONNECT response headers
68
+ ```
69
+
70
+ ## Example: RESTful API
71
+
72
+ ```ruby
73
+ require 'ruby_proxy_headers/rest_client'
74
+ require 'json'
75
+
76
+ proxy_opts = {
77
+ proxy: ENV['PROXY_URL'],
78
+ proxy_headers: { 'X-ProxyMesh-Country' => 'US' }
79
+ }
80
+
81
+ # Create
82
+ response = RubyProxyHeaders::RestClient.post(
83
+ 'https://jsonplaceholder.typicode.com/posts',
84
+ payload: { title: 'Test', body: 'Content', userId: 1 }.to_json,
85
+ headers: { 'Content-Type' => 'application/json' },
86
+ **proxy_opts
87
+ )
88
+ puts "Created: #{JSON.parse(response.body)['id']}"
89
+
90
+ # Read
91
+ response = RubyProxyHeaders::RestClient.get(
92
+ 'https://jsonplaceholder.typicode.com/posts/1',
93
+ **proxy_opts
94
+ )
95
+ puts "Read: #{JSON.parse(response.body)['title']}"
96
+ ```
data/docs/testing.md ADDED
@@ -0,0 +1,105 @@
1
+ # Testing
2
+
3
+ This guide covers running tests for ruby-proxy-headers.
4
+
5
+ ## Running Unit Tests
6
+
7
+ ```bash
8
+ # Install dependencies
9
+ bundle install
10
+
11
+ # Run all tests
12
+ rake spec
13
+
14
+ # Run with verbose output
15
+ rake test
16
+
17
+ # Run specific test file
18
+ bundle exec rspec spec/connection_spec.rb
19
+ ```
20
+
21
+ ## Running Integration Tests
22
+
23
+ Integration tests require an actual proxy server.
24
+
25
+ ### Environment Variables
26
+
27
+ ```bash
28
+ # Required
29
+ export PROXY_URL='http://user:pass@proxy.example.com:31280'
30
+
31
+ # Optional
32
+ export TEST_URL='https://api.ipify.org?format=json'
33
+ export PROXY_HEADER='X-ProxyMesh-IP'
34
+ export SEND_PROXY_HEADER='X-ProxyMesh-Country'
35
+ export SEND_PROXY_VALUE='US'
36
+ ```
37
+
38
+ ### Running Tests
39
+
40
+ ```bash
41
+ # Run all integration tests
42
+ ruby test/test_proxy_headers.rb
43
+
44
+ # Run specific tests
45
+ ruby test/test_proxy_headers.rb net_http faraday
46
+
47
+ # List available tests
48
+ ruby test/test_proxy_headers.rb -l
49
+
50
+ # Verbose output
51
+ ruby test/test_proxy_headers.rb -v
52
+ ```
53
+
54
+ The harness prints `Testing <module>... OK` or `FAIL`. Module names are `net_http`, `faraday`, `httparty`, `excon`, `typhoeus`, `http_gem`, and `rest_client`. Excon checks that the proxied request succeeds. It does not read CONNECT response headers.
55
+
56
+ ## Writing Tests
57
+
58
+ ### Unit Test Example
59
+
60
+ ```ruby
61
+ # spec/my_module_spec.rb
62
+ require 'spec_helper'
63
+
64
+ RSpec.describe MyModule do
65
+ describe '#my_method' do
66
+ it 'does something' do
67
+ result = MyModule.my_method
68
+ expect(result).to eq(expected_value)
69
+ end
70
+ end
71
+ end
72
+ ```
73
+
74
+ ### Integration Test Example
75
+
76
+ ```ruby
77
+ # test/my_integration_test.rb
78
+ require 'uri'
79
+ require_relative '../lib/ruby_proxy_headers'
80
+
81
+ proxy_url = ENV['PROXY_URL']
82
+ raise 'Set PROXY_URL' unless proxy_url
83
+
84
+ RubyProxyHeaders::NetHTTP.patch!
85
+ uri = URI('https://api.ipify.org?format=json')
86
+ proxy = URI(proxy_url)
87
+ http = Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, proxy.user, proxy.password)
88
+ http.use_ssl = true
89
+ http.proxy_connect_request_headers = { 'X-Test' => 'value' }
90
+ response = http.request(Net::HTTP::Get.new(uri))
91
+ puts response.code
92
+ puts http.last_proxy_connect_response_headers['X-ProxyMesh-IP']
93
+ ```
94
+
95
+ ## CI
96
+
97
+ `.github/workflows/unit_tests.yml` runs `bundle exec rspec` on Ruby 3.2 and 3.3. `.github/workflows/proxy_integration_tests.yml` runs `test/test_proxy_headers.rb` when the `PROXY_URL` Actions secret is set.
98
+
99
+ ## Troubleshooting
100
+
101
+ Set `open_timeout` and `read_timeout` on the client you are using. For a mock origin with a self-signed certificate, disable verification on that client (`verify_mode`, Faraday `ssl.verify`, HTTP.rb `verify_ssl: false`, or RestClient `verify_ssl: false`). Do not do that against a public site.
102
+
103
+ ```bash
104
+ bundle install
105
+ ```
data/docs/typhoeus.md ADDED
@@ -0,0 +1,47 @@
1
+ # Typhoeus
2
+
3
+ [Typhoeus](https://typhoeus.github.io/) wraps libcurl. `RubyProxyHeaders::Typhoeus` sends custom proxy headers with `CURLOPT_PROXYHEADER` and reads the CONNECT response.
4
+
5
+ Requires Typhoeus 1.7 (Ethon >= 0.19.1) and libcurl with `CURLOPT_PROXYHEADER`. The gem does not depend on Typhoeus at runtime.
6
+
7
+ Proxy header names are documented at <https://docs.proxymesh.com/>.
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ gem 'typhoeus', '~> 1.7'
13
+ gem 'ruby-proxy-headers'
14
+ ```
15
+
16
+ ## Basic Usage
17
+
18
+ ```ruby
19
+ require 'ruby_proxy_headers/typhoeus'
20
+
21
+ response = RubyProxyHeaders::Typhoeus.get(
22
+ 'https://api.ipify.org?format=json',
23
+ proxy: 'http://user:pass@proxy.example.com:31280',
24
+ proxy_headers: { 'X-ProxyMesh-Country' => 'US' }
25
+ )
26
+
27
+ puts response.code
28
+ puts response.body
29
+ puts response.proxy_response_headers['X-ProxyMesh-IP']
30
+ ```
31
+
32
+ `proxy_headers` are sent only to the proxy (`headeropt: :separate`). On HTTPS that is the CONNECT request. `headers:` is the origin request and is not copied onto CONNECT.
33
+
34
+ `response.headers` stays the origin response. The CONNECT response, including `X-ProxyMesh-IP`, is `response.proxy_response_headers`. Lookup is case-insensitive.
35
+
36
+ `post` takes the same proxy arguments plus `body:`.
37
+
38
+ ```ruby
39
+ RubyProxyHeaders::Typhoeus.post(
40
+ 'https://api.ipify.org?format=json',
41
+ proxy: 'http://user:pass@proxy.example.com:31280',
42
+ proxy_headers: { 'X-ProxyMesh-Country' => 'US' },
43
+ body: '{"q":"example"}'
44
+ )
45
+ ```
46
+
47
+ Header names and values are rejected when they contain CR, LF, or NUL.
@@ -135,14 +135,7 @@ module RubyProxyHeaders
135
135
  end
136
136
 
137
137
  def validate_connect_target!(host, port)
138
- if RubyProxyHeaders::INVALID_HEADER_VALUE_RE.match?(host.to_s)
139
- raise ArgumentError,
140
- "CONNECT target host contains invalid characters (CR, LF, or NUL): #{host.inspect}"
141
- end
142
- if RubyProxyHeaders::INVALID_HEADER_VALUE_RE.match?(port.to_s)
143
- raise ArgumentError,
144
- "CONNECT target port contains invalid characters (CR, LF, or NUL): #{port.inspect}"
145
- end
138
+ RubyProxyHeaders.validate_connect_target!(host, port)
146
139
  end
147
140
 
148
141
  def raise_connect_error
@@ -14,28 +14,31 @@ end
14
14
  module RubyProxyHeaders
15
15
  # Faraday adapter that merges proxy CONNECT response headers into Faraday response headers.
16
16
  # Use with {RubyProxyHeaders::NetHTTP.patch!}.
17
+ #
18
+ # faraday-net_http 3.4 and 3.5 perform the request in the private method
19
+ # +request_with_wrapped_block+. This adapter does not override that method.
20
+ # It uses the public +build_connection+ and +call+ methods.
17
21
  module FaradayAdapter
22
+ # Copies CONNECT response headers onto the Faraday response.
18
23
  class NetHttp < ::Faraday::Adapter::NetHttp
19
- def request_with_wrapped_block(http, env, &block)
20
- res = super
21
- merge_proxy_headers(env, http)
22
- res
24
+ def build_connection(env)
25
+ super.tap { |http| Thread.current[:ruby_proxy_headers_faraday_http] = http }
23
26
  end
24
27
 
25
- private
28
+ def call(env)
29
+ response = super
30
+ http = Thread.current[:ruby_proxy_headers_faraday_http]
31
+ headers = http.last_proxy_connect_response_headers if http
32
+ if env[:response_headers] && headers
33
+ headers.each do |key, value|
34
+ next if value.nil?
26
35
 
27
- def merge_proxy_headers(env, http)
28
- return unless env[:response_headers]
29
- return unless http.respond_to?(:last_proxy_connect_response_headers)
30
-
31
- ph = http.last_proxy_connect_response_headers
32
- return unless ph.is_a?(Hash)
33
-
34
- ph.each do |k, v|
35
- next if v.nil?
36
-
37
- env[:response_headers][k] ||= v
36
+ env[:response_headers][key] ||= value
37
+ end
38
38
  end
39
+ response
40
+ ensure
41
+ Thread.current[:ruby_proxy_headers_faraday_http] = nil
39
42
  end
40
43
  end
41
44
  end
@@ -1,48 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'delegate'
4
+ require 'openssl'
5
+ require 'uri'
6
+ require_relative '../ruby_proxy_headers'
7
+
8
+ begin
9
+ require 'http'
10
+ rescue LoadError => e
11
+ raise LoadError, "the http gem >= 6 is required for ruby_proxy_headers/http_gem (#{e.message})"
12
+ end
13
+
3
14
  module RubyProxyHeaders
4
- # HTTP.rb (http gem) integration for proxy headers support.
15
+ # HTTP.rb (the +http+ gem) sends +proxy_headers+ on CONNECT and returns them
16
+ # as +response.proxy_headers+. This wrapper adds +proxy_response_headers+,
17
+ # matching the other adapters.
5
18
  #
6
19
  # @example
7
20
  # client = RubyProxyHeaders::HTTPGem.create_client(
8
- # proxy: 'http://user:pass@proxy:8080',
21
+ # proxy: 'http://user:pass@proxy.example.com:31280',
9
22
  # proxy_headers: { 'X-ProxyMesh-Country' => 'US' }
10
23
  # )
11
- #
12
24
  # response = client.get('https://example.com')
13
- # puts response.proxy_response_headers
14
- #
25
+ # puts response.proxy_response_headers['X-ProxyMesh-IP']
15
26
  module HTTPGem
16
- # Create an HTTP client with proxy header support.
17
- # @param proxy [String] Proxy URL
18
- # @param proxy_headers [Hash] Custom headers to send to proxy
19
- # @param options [Hash] Additional HTTP.rb options
20
- # @return [ProxyClient]
21
27
  def self.create_client(proxy:, proxy_headers: {}, **options)
22
28
  ProxyClient.new(proxy: proxy, proxy_headers: proxy_headers, **options)
23
29
  end
24
30
 
25
- # Make a GET request with proxy headers.
26
31
  def self.get(url, proxy:, proxy_headers: {}, **options)
27
32
  create_client(proxy: proxy, proxy_headers: proxy_headers).get(url, **options)
28
33
  end
29
34
 
30
- # Make a POST request with proxy headers.
31
35
  def self.post(url, proxy:, proxy_headers: {}, body: nil, **options)
32
36
  create_client(proxy: proxy, proxy_headers: proxy_headers).post(url, body: body, **options)
33
37
  end
34
38
 
35
- # Proxy client wrapper for HTTP.rb
39
+ # HTTP.rb client that sends proxy_headers on CONNECT.
36
40
  class ProxyClient
41
+ attr_reader :last_proxy_response_headers
42
+
43
+ REQUEST_OPTIONS = %i[open_timeout read_timeout headers body verify_ssl].freeze
44
+
37
45
  def initialize(proxy:, proxy_headers: {}, **options)
38
46
  @proxy = proxy
39
47
  @proxy_headers = proxy_headers
40
- @options = options
41
- @last_proxy_response_headers = nil
48
+ @default_options = options
42
49
  end
43
50
 
44
- attr_reader :last_proxy_response_headers
45
-
46
51
  def get(url, **options)
47
52
  request(:get, url, **options)
48
53
  end
@@ -62,32 +67,48 @@ module RubyProxyHeaders
62
67
  private
63
68
 
64
69
  def request(method, url, **options)
65
- uri = URI.parse(url)
66
-
67
- # Use our core connection for HTTPS
68
- if uri.scheme == 'https'
69
- response = RubyProxyHeaders::NetHTTP.request(
70
- method,
71
- url,
72
- proxy: @proxy,
73
- proxy_headers: @proxy_headers,
74
- headers: options[:headers],
75
- body: options[:body]
76
- )
77
- @last_proxy_response_headers = response.proxy_response_headers
78
- return response
70
+ options = @default_options.merge(options)
71
+ unknown = options.keys - REQUEST_OPTIONS
72
+ raise ArgumentError, "unknown option: #{unknown.first}" unless unknown.empty?
73
+
74
+ @proxy_headers.each { |key, value| RubyProxyHeaders.validate_header!(key, value) }
75
+ proxy_uri = URI.parse(@proxy)
76
+ user = proxy_uri.user && URI.decode_www_form_component(proxy_uri.user)
77
+ password = proxy_uri.password && URI.decode_www_form_component(proxy_uri.password)
78
+ client = ::HTTP.via(proxy_uri.host, proxy_uri.port, user, password, @proxy_headers)
79
+ timeouts = {}
80
+ timeouts[:connect] = options[:open_timeout] if options[:open_timeout]
81
+ timeouts[:read] = options[:read_timeout] if options[:read_timeout]
82
+ client = client.timeout(timeouts) unless timeouts.empty?
83
+
84
+ request_options = {}
85
+ request_options[:headers] = options[:headers] if options[:headers]
86
+ request_options[:body] = options[:body] unless options[:body].nil?
87
+ if options[:verify_ssl] == false
88
+ context = OpenSSL::SSL::SSLContext.new
89
+ context.verify_mode = OpenSSL::SSL::VERIFY_NONE
90
+ request_options[:ssl_context] = context
91
+ end
92
+
93
+ response = client.request(method, url, **request_options)
94
+ # HTTP::Headers is not a Hash. Copy into HeaderMap so callers can
95
+ # read X-ProxyMesh-IP the same way as the other adapters.
96
+ mapped = HeaderMap.new
97
+ response.proxy_headers.each do |name, value|
98
+ mapped[name] = mapped.key?(name) ? "#{mapped[name]}, #{value}" : value
79
99
  end
100
+ @last_proxy_response_headers = mapped
101
+ ProxyResponse.new(response, mapped)
102
+ end
103
+ end
80
104
 
81
- # For HTTP, use standard http gem
82
- require 'http'
105
+ # HTTP::Response plus +proxy_response_headers+.
106
+ class ProxyResponse < SimpleDelegator
107
+ attr_reader :proxy_response_headers
83
108
 
84
- proxy_uri = URI.parse(@proxy)
85
- ::HTTP.via(
86
- proxy_uri.host,
87
- proxy_uri.port,
88
- proxy_uri.user,
89
- proxy_uri.password
90
- ).send(method, url, **options)
109
+ def initialize(response, proxy_headers)
110
+ super(response)
111
+ @proxy_response_headers = proxy_headers
91
112
  end
92
113
  end
93
114
  end