ruby-proxy-headers 0.2.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.
- checksums.yaml +7 -0
- data/DEFERRED.md +29 -0
- data/IMPLEMENTATION_PRIORITY.md +72 -0
- data/LIBRARY_RESEARCH.md +71 -0
- data/LICENSE +21 -0
- data/README.md +133 -0
- data/lib/ruby_proxy_headers/connection.rb +143 -0
- data/lib/ruby_proxy_headers/excon.rb +38 -0
- data/lib/ruby_proxy_headers/faraday.rb +73 -0
- data/lib/ruby_proxy_headers/http_gem.rb +94 -0
- data/lib/ruby_proxy_headers/httparty.rb +27 -0
- data/lib/ruby_proxy_headers/net_http.rb +172 -0
- data/lib/ruby_proxy_headers/rest_client.rb +90 -0
- data/lib/ruby_proxy_headers/typhoeus.rb +102 -0
- data/lib/ruby_proxy_headers/version.rb +5 -0
- data/lib/ruby_proxy_headers.rb +11 -0
- metadata +158 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3a39a089e5181641efdaa2920edfd958bc127311e54be9942fdf5ae563ded24a
|
|
4
|
+
data.tar.gz: 0c138a06ea8d8b6dad7ed943b40e67964d05b0daf680e68ac1825a565647596a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5efd2419ca3cc85309f54823e5a98b59b465cbf98bc32b39503aa9ff11b14732fef71cc6bd5bdb413083b1addeb269bb22b4bdc5093cac29083419823173a234
|
|
7
|
+
data.tar.gz: 4fbaf515b270d7544a70bbfab697fe5594820161733290761367f6daee2d225484f3baaea9283420a1230a088a5dd981d7ca9d6a2c7a44f2c5a07e585cb66aa8
|
data/DEFERRED.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Deferred / not fully supported
|
|
2
|
+
|
|
3
|
+
## Typhoeus / Ethon
|
|
4
|
+
|
|
5
|
+
**Status:** Not implemented in this gem.
|
|
6
|
+
|
|
7
|
+
[Ethon](https://github.com/typhoeus/ethon) does not expose `CURLOPT_PROXYHEADER` (or equivalent) in its `Ethon::Easy` option mapping as of ethon 0.18.x. Adding CONNECT response header capture would require Ethon/libcurl changes or a custom C extension.
|
|
8
|
+
|
|
9
|
+
**Workaround:** Use `Net::HTTP` + {RubyProxyHeaders::NetHTTP.patch!} or Faraday with `ruby_proxy_headers_net_http` adapter.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Mechanize
|
|
14
|
+
|
|
15
|
+
**Status:** Not implemented.
|
|
16
|
+
|
|
17
|
+
[Mechanize](https://github.com/sparklemotion/mechanize) uses [net-http-persistent](https://github.com/drbrain/net-http-persistent), which maintains its own connection layer on top of `Net::HTTP`. Our prepend patch targets `Net::HTTP#connect` on instances Mechanize creates, but verifying header propagation and lifecycle across the persistent pool needs dedicated work.
|
|
18
|
+
|
|
19
|
+
**Workaround:** Use patched `Net::HTTP` or Faraday for fetches; use Mechanize only when custom CONNECT headers are not required.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Excon — reading CONNECT response headers
|
|
24
|
+
|
|
25
|
+
**Status:** Sending extra CONNECT headers is supported upstream via `:ssl_proxy_headers`.
|
|
26
|
+
|
|
27
|
+
Excon’s public `Excon::Response` for the **origin** request does **not** include headers from the proxy’s `CONNECT` response (only the tunneled HTTPS response headers). Capturing `X-ProxyMesh-IP` from CONNECT would require patching Excon internals or a fork.
|
|
28
|
+
|
|
29
|
+
**Workaround:** Use Net::HTTP / Faraday integrations in this gem, which merge CONNECT headers into the client response where applicable.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# ruby-proxy-headers — implementation plan
|
|
2
|
+
|
|
3
|
+
Prioritized roadmap for extension modules, aligned with [javascript-proxy-headers](https://github.com/proxymesh/javascript-proxy-headers) and [python-proxy-headers](https://github.com/proxymesh/python-proxy-headers).
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ ruby-proxy-headers │
|
|
10
|
+
├─────────────────────────────────────────────────────────────┤
|
|
11
|
+
│ Faraday / HTTParty / Excon helpers │
|
|
12
|
+
│ │ │
|
|
13
|
+
│ ▼ │
|
|
14
|
+
│ Net::HTTP patch — CONNECT send + capture + thread-local │
|
|
15
|
+
└─────────────────────────────────────────────────────────────┘
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Phase 1 — Net::HTTP core (**done — v0.1+**)
|
|
19
|
+
|
|
20
|
+
- `RubyProxyHeaders::NetHTTP.patch!` extends `Net::HTTP#connect` for HTTPS + proxy.
|
|
21
|
+
- `last_proxy_connect_response_headers` on the `Net::HTTP` instance.
|
|
22
|
+
- `Thread.current[:ruby_proxy_headers_connect_headers]` + `RubyProxyHeaders.proxy_connect_response_headers` for cross-library reads.
|
|
23
|
+
|
|
24
|
+
**File:** `lib/ruby_proxy_headers/net_http.rb`
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Phase 2 — Faraday (**done — v0.2+**)
|
|
29
|
+
|
|
30
|
+
- Adapter `ruby_proxy_headers_net_http` (subclass of `Faraday::Adapter::NetHttp`) merges CONNECT response headers into Faraday response headers.
|
|
31
|
+
- Helper `RubyProxyHeaders::FaradayIntegration.connection(...)`.
|
|
32
|
+
|
|
33
|
+
**File:** `lib/ruby_proxy_headers/faraday.rb`
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Phase 3 — HTTParty (**done — v0.2+**)
|
|
38
|
+
|
|
39
|
+
- `RubyProxyHeaders::ProxyHeadersConnectionAdapter` — pass `proxy_connect_request_headers` in HTTParty options.
|
|
40
|
+
|
|
41
|
+
**File:** `lib/ruby_proxy_headers/httparty.rb`
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Phase 4 — Typhoeus / Ethon (**deferred**)
|
|
46
|
+
|
|
47
|
+
Ethon does not expose libcurl `CURLOPT_PROXYHEADER` in its option layer. See [DEFERRED.md](DEFERRED.md).
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Phase 5 — Excon (**partial — v0.2+**)
|
|
52
|
+
|
|
53
|
+
- **Sending** extra CONNECT headers: supported upstream via `:ssl_proxy_headers`; `RubyProxyHeaders::ExconIntegration.get` passes them through.
|
|
54
|
+
- **Reading** CONNECT response headers: not exposed on `Excon::Response` for the tunneled request. See [DEFERRED.md](DEFERRED.md).
|
|
55
|
+
|
|
56
|
+
**File:** `lib/ruby_proxy_headers/excon.rb`
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Phase 6 — Mechanize (**deferred**)
|
|
61
|
+
|
|
62
|
+
Uses `net-http-persistent`; needs dedicated integration. See [DEFERRED.md](DEFERRED.md).
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Testing
|
|
67
|
+
|
|
68
|
+
`bundle exec ruby test/test_proxy_headers.rb` with `PROXY_URL` set. Modules: `net_http`, `faraday`, `httparty`, `excon` (excon is a smoke test; CONNECT response headers are not asserted).
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
*Updated: March 2026*
|
data/LIBRARY_RESEARCH.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Ruby library proxy header support (CONNECT tunnel)
|
|
2
|
+
|
|
3
|
+
This document analyzes the Ruby HTTP / scraping stack used in [proxy-examples](https://github.com/proxymesh/proxy-examples/tree/main/ruby) for **custom headers on HTTPS `CONNECT`** and **reading the proxy `CONNECT` response headers**.
|
|
4
|
+
|
|
5
|
+
## Executive summary
|
|
6
|
+
|
|
7
|
+
| Library | Native CONNECT header API | Extension approach |
|
|
8
|
+
|---------|---------------------------|---------------------|
|
|
9
|
+
| **Net::HTTP** (stdlib) | No — tunnel is hard-coded in `#connect` | **Implemented:** prepend patch; optional `proxy_connect_request_headers`; `last_proxy_connect_response_headers` |
|
|
10
|
+
| **Faraday** | No — delegates to adapters (`net_http`, etc.) | **High:** use patched `Net::HTTP` with `Faraday.new(connection_options)` or custom adapter that sets `proxy_connect_request_headers` |
|
|
11
|
+
| **HTTParty** | No — built on `Net::HTTP` | **High:** global or per-class `Net::HTTP` instances after `patch!`; or subclass `Connection` if needed |
|
|
12
|
+
| **Mechanize** | No — uses `Net::HTTP` / persistent connections internally | **Medium–high:** ensure Mechanize’s internal HTTP object gets the same patched behavior and header accessors |
|
|
13
|
+
| **Excon** | **Send:** yes — `:ssl_proxy_headers` on connection. **Read CONNECT response:** not exposed on origin `Excon::Response` | **Partial:** use `:ssl_proxy_headers` for sends; reading `X-ProxyMesh-IP` from CONNECT needs upstream Excon changes (see [DEFERRED.md](DEFERRED.md)) |
|
|
14
|
+
| **Typhoeus / Ethon** | Partial — libcurl has proxy options | **Medium:** map headers to `CURLOPT_PROXYHEADER` / related options (libcurl version dependent); capture CONNECT response via callbacks or debug hooks where supported |
|
|
15
|
+
| **Nokogiri** | N/A (XML/HTML parser only) | **N/A** — proxying is whatever HTTP client fetches the document (usually `Net::HTTP`) |
|
|
16
|
+
|
|
17
|
+
As with Node and Python stacks, **none** of the high-level Ruby clients expose a first-class “proxy CONNECT request/response headers” API; extensions must hook **below** the HTTP library (stdlib `Net::HTTP`), **inside** Faraday’s adapter, or **at** the libcurl / Excon layer.
|
|
18
|
+
|
|
19
|
+
## Connection flow (HTTPS over HTTP proxy)
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Client -- CONNECT + custom headers --> Proxy
|
|
23
|
+
Client <-- CONNECT response headers --- Proxy
|
|
24
|
+
Client === TLS tunnel =================> Origin
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Net::HTTP (stdlib)
|
|
28
|
+
|
|
29
|
+
MRI implements the tunnel in `Net::HTTP#connect` (see `net/http.rb`): it writes `CONNECT host:port`, `Host`, optional `Proxy-Authorization`, then reads the response with `Net::HTTPResponse.read_new` and calls `#value` without exposing headers to callers.
|
|
30
|
+
|
|
31
|
+
**Feasibility:** High — same pattern as Python’s `HTTPSConnection._tunnel` override in `python-proxy-headers`.
|
|
32
|
+
|
|
33
|
+
## Faraday
|
|
34
|
+
|
|
35
|
+
Uses adapters; default `net_http` adapter constructs `Net::HTTP`. If `Net::HTTP` is patched and exposes `proxy_connect_request_headers`, Faraday can pass options through `connection` / `request` options in a thin wrapper.
|
|
36
|
+
|
|
37
|
+
**Feasibility:** High once `Net::HTTP` support is stable.
|
|
38
|
+
|
|
39
|
+
## HTTParty
|
|
40
|
+
|
|
41
|
+
Uses `Net::HTTP` under the hood for sync requests. After `RubyProxyHeaders::NetHTTP.patch!`, new `Net::HTTP` objects support the new accessors.
|
|
42
|
+
|
|
43
|
+
**Feasibility:** High — may need documented patterns for `default_options` and connection lifecycle.
|
|
44
|
+
|
|
45
|
+
## Mechanize
|
|
46
|
+
|
|
47
|
+
Builds on `net/http` (often `net-http-persistent`). May require verifying that the patched `connect` runs for its connections and that response/header capture is visible on the right object.
|
|
48
|
+
|
|
49
|
+
**Feasibility:** Medium.
|
|
50
|
+
|
|
51
|
+
## Excon
|
|
52
|
+
|
|
53
|
+
Own implementation of proxy and TLS; does not use `Net::HTTP#connect`.
|
|
54
|
+
|
|
55
|
+
**Feasibility:** Medium — requires a dedicated Excon middleware or socket layer similar to the JavaScript core agent.
|
|
56
|
+
|
|
57
|
+
## Typhoeus / Ethon (libcurl)
|
|
58
|
+
|
|
59
|
+
libcurl can send additional headers to the proxy; exact options depend on libcurl version (e.g. proxy header lists). CONNECT response header visibility may require `CURLOPT_DEBUGFUNCTION` or version-specific features.
|
|
60
|
+
|
|
61
|
+
**Feasibility:** Medium — needs research per libcurl version and Ethon API surface.
|
|
62
|
+
|
|
63
|
+
## Nokogiri
|
|
64
|
+
|
|
65
|
+
Parsing only; no network layer.
|
|
66
|
+
|
|
67
|
+
**Feasibility:** N/A — combine with `Net::HTTP` + patch or another extended client.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
*Last updated: March 2026*
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ProxyMesh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Ruby Proxy Headers
|
|
2
|
+
|
|
3
|
+
Send and receive custom proxy headers during HTTPS `CONNECT` tunneling in modern Ruby HTTP workflows (for example [ProxyMesh](https://proxymesh.com) `X-ProxyMesh-IP` / `X-ProxyMesh-Country`).
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
Most Ruby HTTP clients use `Net::HTTP`, Faraday, or libcurl without exposing:
|
|
8
|
+
|
|
9
|
+
1. Extra headers on the `CONNECT` request to the proxy.
|
|
10
|
+
2. Headers from the proxy’s `CONNECT` response (often discarded after the tunnel is established).
|
|
11
|
+
|
|
12
|
+
This library adds opt-in support for **Net::HTTP**, **Faraday** (2.x via `faraday-net_http`), **HTTParty**, and documents **Excon**’s built-in `:ssl_proxy_headers` for sends.
|
|
13
|
+
|
|
14
|
+
## Why teams use this
|
|
15
|
+
|
|
16
|
+
- **Geo-targeting at tunnel setup**: Send country/session directives on `CONNECT`.
|
|
17
|
+
- **Sticky-session observability**: Read proxy-assigned headers like `X-ProxyMesh-IP`.
|
|
18
|
+
- **Works with common Ruby stacks**: Useful for scraping and API clients using Net::HTTP-based flows.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
gem install ruby-proxy-headers
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or add to your `Gemfile`:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
gem 'ruby-proxy-headers'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The `Net::HTTP` patch is pure Ruby. Install **faraday**, **faraday-net_http**, **httparty**, and/or **excon** when you use those integrations.
|
|
33
|
+
|
|
34
|
+
## Net::HTTP
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
require 'uri'
|
|
38
|
+
require 'openssl'
|
|
39
|
+
require 'ruby_proxy_headers/net_http'
|
|
40
|
+
|
|
41
|
+
RubyProxyHeaders::NetHTTP.patch!
|
|
42
|
+
|
|
43
|
+
uri = URI('https://api.ipify.org?format=json')
|
|
44
|
+
proxy = URI(ENV.fetch('PROXY_URL'))
|
|
45
|
+
|
|
46
|
+
http = Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, proxy.user, proxy.password)
|
|
47
|
+
http.use_ssl = true
|
|
48
|
+
|
|
49
|
+
# Optional: headers to send on CONNECT (e.g. sticky IP)
|
|
50
|
+
http.proxy_connect_request_headers = { 'X-ProxyMesh-IP' => '203.0.113.1' }
|
|
51
|
+
|
|
52
|
+
res = http.request(Net::HTTP::Get.new(uri))
|
|
53
|
+
puts res.body
|
|
54
|
+
puts http.last_proxy_connect_response_headers['X-ProxyMesh-IP']
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Call `RubyProxyHeaders::NetHTTP.patch!` once before creating connections. You can also read the last CONNECT response headers on the current thread via `RubyProxyHeaders.proxy_connect_response_headers`.
|
|
58
|
+
|
|
59
|
+
## Faraday
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
require 'ruby_proxy_headers/faraday'
|
|
63
|
+
|
|
64
|
+
conn = RubyProxyHeaders::FaradayIntegration.connection(
|
|
65
|
+
proxy: ENV.fetch('PROXY_URL'),
|
|
66
|
+
proxy_connect_headers: { 'X-ProxyMesh-Country' => 'US' } # optional
|
|
67
|
+
)
|
|
68
|
+
res = conn.get('https://api.ipify.org?format=json')
|
|
69
|
+
puts res.headers['X-ProxyMesh-IP']
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Uses the registered adapter `:ruby_proxy_headers_net_http`, which merges proxy `CONNECT` response headers into Faraday’s response headers.
|
|
73
|
+
|
|
74
|
+
## HTTParty
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
require 'httparty'
|
|
78
|
+
require 'ruby_proxy_headers/httparty'
|
|
79
|
+
|
|
80
|
+
RubyProxyHeaders::NetHTTP.patch!
|
|
81
|
+
|
|
82
|
+
proxy = URI(ENV.fetch('PROXY_URL'))
|
|
83
|
+
HTTParty.get(
|
|
84
|
+
'https://api.ipify.org?format=json',
|
|
85
|
+
http_proxyaddr: proxy.host,
|
|
86
|
+
http_proxyport: proxy.port,
|
|
87
|
+
http_proxyuser: proxy.user,
|
|
88
|
+
http_proxypass: proxy.password,
|
|
89
|
+
proxy_connect_request_headers: { 'X-ProxyMesh-IP' => '203.0.113.1' }, # optional
|
|
90
|
+
connection_adapter: RubyProxyHeaders::ProxyHeadersConnectionAdapter
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
puts RubyProxyHeaders.proxy_connect_response_headers['X-ProxyMesh-IP']
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Excon (send-only; CONNECT response headers)
|
|
97
|
+
|
|
98
|
+
Excon supports **sending** extra headers on CONNECT with `:ssl_proxy_headers`. Reading `X-ProxyMesh-IP` from the CONNECT response is **not** exposed on the origin response object — see [DEFERRED.md](DEFERRED.md).
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
require 'ruby_proxy_headers/excon'
|
|
102
|
+
|
|
103
|
+
RubyProxyHeaders::ExconIntegration.get(
|
|
104
|
+
'https://api.ipify.org?format=json',
|
|
105
|
+
proxy_url: ENV.fetch('PROXY_URL'),
|
|
106
|
+
proxy_connect_headers: { 'X-ProxyMesh-Country' => 'US' } # optional
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Testing (live proxy)
|
|
111
|
+
|
|
112
|
+
Same environment variables as [python-proxy-headers](https://github.com/proxymesh/python-proxy-headers):
|
|
113
|
+
|
|
114
|
+
| Variable | Role |
|
|
115
|
+
|----------|------|
|
|
116
|
+
| `PROXY_URL` | Proxy URL (required for tests) |
|
|
117
|
+
| `TEST_URL` | Target HTTPS URL (default `https://api.ipify.org?format=json`) |
|
|
118
|
+
| `PROXY_HEADER` | Header to read from CONNECT response (default `X-ProxyMesh-IP`) |
|
|
119
|
+
| `SEND_PROXY_HEADER` | Optional header name to send on `CONNECT` |
|
|
120
|
+
| `SEND_PROXY_VALUE` | Optional value for that header |
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
cd ruby-proxy-headers
|
|
124
|
+
bundle install
|
|
125
|
+
export PROXY_URL=http://user:pass@proxyhost:port
|
|
126
|
+
bundle exec ruby test/test_proxy_headers.rb -v
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Related
|
|
130
|
+
|
|
131
|
+
- [python-proxy-headers](https://github.com/proxymesh/python-proxy-headers)
|
|
132
|
+
- [javascript-proxy-headers](https://github.com/proxymesh/javascript-proxy-headers)
|
|
133
|
+
- [proxy-examples (Ruby)](https://github.com/proxymesh/proxy-examples/tree/main/ruby)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module RubyProxyHeaders
|
|
8
|
+
# Handles HTTPS CONNECT tunneling with custom proxy headers.
|
|
9
|
+
# This is the core component that manages the proxy connection,
|
|
10
|
+
# sends custom headers, and captures proxy response headers.
|
|
11
|
+
class Connection
|
|
12
|
+
attr_reader :proxy_response_headers, :proxy_response_status, :socket
|
|
13
|
+
|
|
14
|
+
# @param proxy [String, Hash] Proxy URL or hash with :host, :port, :user, :password
|
|
15
|
+
# @param options [Hash] Connection options
|
|
16
|
+
# @option options [Hash] :proxy_headers Custom headers to send during CONNECT
|
|
17
|
+
# @option options [Integer] :connect_timeout Connection timeout in seconds (default: 30)
|
|
18
|
+
# @option options [Boolean] :verify_ssl Verify SSL certificates (default: true)
|
|
19
|
+
def initialize(proxy, options = {})
|
|
20
|
+
@proxy = proxy.is_a?(String) ? RubyProxyHeaders.parse_proxy_url(proxy) : proxy
|
|
21
|
+
@proxy_headers = options[:proxy_headers] || {}
|
|
22
|
+
@connect_timeout = options[:connect_timeout] || 30
|
|
23
|
+
@verify_ssl = options.fetch(:verify_ssl, true)
|
|
24
|
+
@proxy_response_headers = {}
|
|
25
|
+
@proxy_response_status = nil
|
|
26
|
+
@socket = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Establish a tunnel through the proxy to the target host.
|
|
30
|
+
# @param target_host [String] Target hostname
|
|
31
|
+
# @param target_port [Integer] Target port (default: 443)
|
|
32
|
+
# @return [OpenSSL::SSL::SSLSocket] TLS-wrapped socket to target
|
|
33
|
+
def connect(target_host, target_port = 443)
|
|
34
|
+
# Connect to proxy
|
|
35
|
+
@socket = TCPSocket.new(@proxy[:host], @proxy[:port])
|
|
36
|
+
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
37
|
+
|
|
38
|
+
# Send CONNECT request with custom headers
|
|
39
|
+
connect_request = build_connect_request(target_host, target_port)
|
|
40
|
+
@socket.write(connect_request)
|
|
41
|
+
|
|
42
|
+
# Read and parse CONNECT response
|
|
43
|
+
response = read_connect_response
|
|
44
|
+
parse_connect_response(response)
|
|
45
|
+
|
|
46
|
+
# Check for successful connection
|
|
47
|
+
unless (200..299).cover?(@proxy_response_status)
|
|
48
|
+
@socket.close
|
|
49
|
+
raise_connect_error
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Upgrade to TLS
|
|
53
|
+
upgrade_to_tls(target_host)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Close the connection
|
|
57
|
+
def close
|
|
58
|
+
@socket&.close
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def build_connect_request(target_host, target_port)
|
|
64
|
+
request_lines = [
|
|
65
|
+
"CONNECT #{target_host}:#{target_port} HTTP/1.1",
|
|
66
|
+
"Host: #{target_host}:#{target_port}"
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# Add proxy authentication if provided
|
|
70
|
+
if @proxy[:user]
|
|
71
|
+
auth = RubyProxyHeaders.build_auth_header(@proxy[:user], @proxy[:password])
|
|
72
|
+
request_lines << "Proxy-Authorization: #{auth}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Add custom proxy headers
|
|
76
|
+
@proxy_headers.each do |name, value|
|
|
77
|
+
request_lines << "#{name}: #{value}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
request_lines << 'Proxy-Connection: keep-alive'
|
|
81
|
+
request_lines << ''
|
|
82
|
+
request_lines << ''
|
|
83
|
+
|
|
84
|
+
request_lines.join("\r\n")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def read_connect_response
|
|
88
|
+
response = String.new
|
|
89
|
+
loop do
|
|
90
|
+
line = @socket.gets
|
|
91
|
+
break if line.nil?
|
|
92
|
+
|
|
93
|
+
response << line
|
|
94
|
+
break if line == "\r\n" || line == "\n"
|
|
95
|
+
end
|
|
96
|
+
response
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_connect_response(response)
|
|
100
|
+
lines = response.split(/\r?\n/)
|
|
101
|
+
return if lines.empty?
|
|
102
|
+
|
|
103
|
+
# Parse status line
|
|
104
|
+
status_line = lines.shift
|
|
105
|
+
if (match = status_line.match(%r{HTTP/[\d.]+\s+(\d+)}))
|
|
106
|
+
@proxy_response_status = match[1].to_i
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Parse headers
|
|
110
|
+
@proxy_response_headers = {}
|
|
111
|
+
lines.each do |line|
|
|
112
|
+
break if line.empty?
|
|
113
|
+
|
|
114
|
+
if (header_match = line.match(/^([^:]+):\s*(.*)$/))
|
|
115
|
+
name = header_match[1].downcase
|
|
116
|
+
value = header_match[2]
|
|
117
|
+
@proxy_response_headers[name] = value
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def upgrade_to_tls(target_host)
|
|
123
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
|
124
|
+
ssl_context.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
125
|
+
|
|
126
|
+
ssl_socket = OpenSSL::SSL::SSLSocket.new(@socket, ssl_context)
|
|
127
|
+
ssl_socket.hostname = target_host
|
|
128
|
+
ssl_socket.sync_close = true
|
|
129
|
+
ssl_socket.connect
|
|
130
|
+
|
|
131
|
+
@socket = ssl_socket
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def raise_connect_error
|
|
135
|
+
case @proxy_response_status
|
|
136
|
+
when 407
|
|
137
|
+
raise ProxyAuthenticationError, "Proxy authentication required (407)"
|
|
138
|
+
else
|
|
139
|
+
raise ConnectError, "Proxy CONNECT failed with status #{@proxy_response_status}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require 'excon'
|
|
5
|
+
rescue LoadError => e
|
|
6
|
+
raise LoadError, "excon is required for ruby_proxy_headers/excon (#{e.message})"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module RubyProxyHeaders
|
|
10
|
+
# Excon already supports extra CONNECT headers via +:ssl_proxy_headers+ (see
|
|
11
|
+
# Excon::SSLSocket). This module documents the mapping and a small helper.
|
|
12
|
+
#
|
|
13
|
+
# Reading CONNECT response headers is not exposed on Excon's public response
|
|
14
|
+
# object for the origin request; use thread-local {RubyProxyHeaders.proxy_connect_response_headers}
|
|
15
|
+
# only when the underlying transport is patched Net::HTTP, not Excon.
|
|
16
|
+
module ExconIntegration
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# @param proxy_url [String]
|
|
20
|
+
# @param proxy_connect_headers [Hash] sent to the proxy during HTTPS CONNECT (Excon key: :ssl_proxy_headers)
|
|
21
|
+
# @param excon_opts [Hash] merged into Excon.get / Excon.new options
|
|
22
|
+
def get(url, proxy_url:, proxy_connect_headers: nil, **excon_opts)
|
|
23
|
+
opts = {
|
|
24
|
+
proxy: normalize_proxy_url(proxy_url),
|
|
25
|
+
ssl_proxy_headers: proxy_connect_headers,
|
|
26
|
+
ssl_verify_peer: true
|
|
27
|
+
}.merge(excon_opts)
|
|
28
|
+
Excon.get(url, opts)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def normalize_proxy_url(proxy_url)
|
|
32
|
+
s = proxy_url.to_s.strip
|
|
33
|
+
return s if s.match?(/\A[a-z][a-z0-9+\-.]*:\/\//i)
|
|
34
|
+
|
|
35
|
+
"http://#{s}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'net_http'
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require 'faraday'
|
|
7
|
+
require 'faraday/net_http'
|
|
8
|
+
rescue LoadError => e
|
|
9
|
+
raise LoadError,
|
|
10
|
+
'faraday and faraday-net_http are required for ruby_proxy_headers/faraday ' \
|
|
11
|
+
"(#{e.message})"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module RubyProxyHeaders
|
|
15
|
+
# Faraday adapter that merges proxy CONNECT response headers into Faraday response headers.
|
|
16
|
+
# Use with {RubyProxyHeaders::NetHTTP.patch!}.
|
|
17
|
+
module FaradayAdapter
|
|
18
|
+
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
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
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
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Faraday::Adapter.register_middleware(
|
|
45
|
+
ruby_proxy_headers_net_http: RubyProxyHeaders::FaradayAdapter::NetHttp
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
module RubyProxyHeaders
|
|
49
|
+
module FaradayIntegration
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
def patch!
|
|
53
|
+
RubyProxyHeaders::NetHTTP.patch!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Builds a Faraday connection with the custom Net::HTTP adapter and optional CONNECT headers.
|
|
57
|
+
#
|
|
58
|
+
# @param proxy [String] proxy URL
|
|
59
|
+
# @param proxy_connect_headers [Hash, nil] headers to send on CONNECT (e.g. X-ProxyMesh-IP)
|
|
60
|
+
# @param url [String, nil] optional base URL
|
|
61
|
+
def connection(proxy:, proxy_connect_headers: nil, url: nil, &block)
|
|
62
|
+
patch!
|
|
63
|
+
opts = { proxy: proxy }
|
|
64
|
+
opts[:url] = url if url
|
|
65
|
+
::Faraday.new(opts) do |f|
|
|
66
|
+
f.adapter :ruby_proxy_headers_net_http do |http|
|
|
67
|
+
http.proxy_connect_request_headers = proxy_connect_headers if proxy_connect_headers&.any?
|
|
68
|
+
end
|
|
69
|
+
yield f if block_given?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyProxyHeaders
|
|
4
|
+
# HTTP.rb (http gem) integration for proxy headers support.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# client = RubyProxyHeaders::HTTPGem.create_client(
|
|
8
|
+
# proxy: 'http://user:pass@proxy:8080',
|
|
9
|
+
# proxy_headers: { 'X-ProxyMesh-Country' => 'US' }
|
|
10
|
+
# )
|
|
11
|
+
#
|
|
12
|
+
# response = client.get('https://example.com')
|
|
13
|
+
# puts response.proxy_response_headers
|
|
14
|
+
#
|
|
15
|
+
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
|
+
def self.create_client(proxy:, proxy_headers: {}, **options)
|
|
22
|
+
ProxyClient.new(proxy: proxy, proxy_headers: proxy_headers, **options)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Make a GET request with proxy headers.
|
|
26
|
+
def self.get(url, proxy:, proxy_headers: {}, **options)
|
|
27
|
+
create_client(proxy: proxy, proxy_headers: proxy_headers).get(url, **options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Make a POST request with proxy headers.
|
|
31
|
+
def self.post(url, proxy:, proxy_headers: {}, body: nil, **options)
|
|
32
|
+
create_client(proxy: proxy, proxy_headers: proxy_headers).post(url, body: body, **options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Proxy client wrapper for HTTP.rb
|
|
36
|
+
class ProxyClient
|
|
37
|
+
def initialize(proxy:, proxy_headers: {}, **options)
|
|
38
|
+
@proxy = proxy
|
|
39
|
+
@proxy_headers = proxy_headers
|
|
40
|
+
@options = options
|
|
41
|
+
@last_proxy_response_headers = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :last_proxy_response_headers
|
|
45
|
+
|
|
46
|
+
def get(url, **options)
|
|
47
|
+
request(:get, url, **options)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def post(url, body: nil, **options)
|
|
51
|
+
request(:post, url, body: body, **options)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def put(url, body: nil, **options)
|
|
55
|
+
request(:put, url, body: body, **options)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def delete(url, **options)
|
|
59
|
+
request(:delete, url, **options)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
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
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# For HTTP, use standard http gem
|
|
82
|
+
require 'http'
|
|
83
|
+
|
|
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)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'net_http'
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require 'httparty'
|
|
7
|
+
rescue LoadError => e
|
|
8
|
+
raise LoadError, "httparty is required for ruby_proxy_headers/httparty (#{e.message})"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module RubyProxyHeaders
|
|
12
|
+
# Drop-in connection adapter: pass +:proxy_connect_request_headers+ in options
|
|
13
|
+
# (along with +http_proxyaddr+ / +http_proxyport+ / etc.).
|
|
14
|
+
#
|
|
15
|
+
# After the request, read CONNECT response headers via
|
|
16
|
+
# {RubyProxyHeaders.proxy_connect_response_headers} (thread-local).
|
|
17
|
+
class ProxyHeadersConnectionAdapter < HTTParty::ConnectionAdapter
|
|
18
|
+
def connection
|
|
19
|
+
http = super
|
|
20
|
+
if http.respond_to?(:proxy_connect_request_headers=)
|
|
21
|
+
h = options[:proxy_connect_request_headers] || options[:proxy_connect_headers]
|
|
22
|
+
http.proxy_connect_request_headers = h if h&.any?
|
|
23
|
+
end
|
|
24
|
+
http
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'net/protocol'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'resolv'
|
|
7
|
+
require 'timeout'
|
|
8
|
+
|
|
9
|
+
module RubyProxyHeaders
|
|
10
|
+
# Patches Net::HTTP#connect for HTTPS-over-proxy so extra headers can be sent on
|
|
11
|
+
# the CONNECT request and response headers can be read (e.g. ProxyMesh
|
|
12
|
+
# X-ProxyMesh-IP). Based on the same tunnel flow as Python's http.client / urllib3
|
|
13
|
+
# extensions in python-proxy-headers.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# require 'ruby_proxy_headers/net_http'
|
|
17
|
+
# RubyProxyHeaders::NetHTTP.patch!
|
|
18
|
+
#
|
|
19
|
+
# http = Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, proxy.user, proxy.password)
|
|
20
|
+
# http.use_ssl = true
|
|
21
|
+
# http.proxy_connect_request_headers = { 'X-ProxyMesh-IP' => '203.0.113.1' }
|
|
22
|
+
# res = http.request(Net::HTTP::Get.new(uri))
|
|
23
|
+
# p http.last_proxy_connect_response_headers
|
|
24
|
+
module NetHTTP
|
|
25
|
+
unless const_defined?(:ORIGINAL_CONNECT, false)
|
|
26
|
+
ORIGINAL_CONNECT = ::Net::HTTP.instance_method(:connect)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module Extension
|
|
30
|
+
attr_accessor :proxy_connect_request_headers
|
|
31
|
+
attr_reader :last_proxy_connect_response_headers
|
|
32
|
+
|
|
33
|
+
def connect
|
|
34
|
+
if use_ssl? && proxy?
|
|
35
|
+
connect_with_proxy_tunnel
|
|
36
|
+
else
|
|
37
|
+
RubyProxyHeaders::NetHTTP::ORIGINAL_CONNECT.bind_call(self)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Duplicates Net::HTTP#connect (MRI 3.2) for the HTTPS + proxy branch, with
|
|
44
|
+
# optional extra CONNECT headers and capture of the CONNECT response headers.
|
|
45
|
+
def connect_with_proxy_tunnel
|
|
46
|
+
s = nil
|
|
47
|
+
if use_ssl?
|
|
48
|
+
@ssl_context = OpenSSL::SSL::SSLContext.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if proxy?
|
|
52
|
+
conn_addr = proxy_address
|
|
53
|
+
conn_port = proxy_port
|
|
54
|
+
else
|
|
55
|
+
conn_addr = conn_address
|
|
56
|
+
conn_port = port
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
debug "opening connection to #{conn_addr}:#{conn_port}..."
|
|
60
|
+
s = Timeout.timeout(@open_timeout, Net::OpenTimeout) do
|
|
61
|
+
begin
|
|
62
|
+
TCPSocket.open(conn_addr, conn_port, @local_host, @local_port)
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
raise e, "Failed to open TCP connection to " \
|
|
65
|
+
"#{conn_addr}:#{conn_port} (#{e.message})"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
s.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
69
|
+
debug 'opened'
|
|
70
|
+
|
|
71
|
+
if use_ssl?
|
|
72
|
+
if proxy?
|
|
73
|
+
plain_sock = Net::BufferedIO.new(s, read_timeout: @read_timeout,
|
|
74
|
+
write_timeout: @write_timeout,
|
|
75
|
+
continue_timeout: @continue_timeout,
|
|
76
|
+
debug_output: @debug_output)
|
|
77
|
+
buf = +"CONNECT #{conn_address}:#{@port} HTTP/#{::Net::HTTP::HTTPVersion}\r\n" \
|
|
78
|
+
"Host: #{@address}:#{@port}\r\n"
|
|
79
|
+
if proxy_user
|
|
80
|
+
credential = ["#{proxy_user}:#{proxy_pass}"].pack('m0')
|
|
81
|
+
buf << "Proxy-Authorization: Basic #{credential}\r\n"
|
|
82
|
+
end
|
|
83
|
+
if proxy_connect_request_headers&.any?
|
|
84
|
+
proxy_connect_request_headers.each do |k, v|
|
|
85
|
+
buf << "#{k}: #{v}\r\n"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
buf << "\r\n"
|
|
89
|
+
plain_sock.write(buf)
|
|
90
|
+
|
|
91
|
+
proxy_res = ::Net::HTTPResponse.read_new(plain_sock)
|
|
92
|
+
@last_proxy_connect_response_headers = {}
|
|
93
|
+
proxy_res.each_header do |k, v|
|
|
94
|
+
@last_proxy_connect_response_headers[k] = v
|
|
95
|
+
end
|
|
96
|
+
Thread.current[:ruby_proxy_headers_connect_headers] = @last_proxy_connect_response_headers.dup
|
|
97
|
+
proxy_res.value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
ssl_parameters = {}
|
|
101
|
+
iv_list = instance_variables
|
|
102
|
+
Net::HTTP::SSL_IVNAMES.each_with_index do |ivname, i|
|
|
103
|
+
if iv_list.include?(ivname)
|
|
104
|
+
value = instance_variable_get(ivname)
|
|
105
|
+
unless value.nil?
|
|
106
|
+
ssl_parameters[Net::HTTP::SSL_ATTRIBUTES[i]] = value
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
@ssl_context.set_params(ssl_parameters)
|
|
111
|
+
unless @ssl_context.session_cache_mode.nil?
|
|
112
|
+
@ssl_context.session_cache_mode =
|
|
113
|
+
OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT |
|
|
114
|
+
OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE
|
|
115
|
+
end
|
|
116
|
+
if @ssl_context.respond_to?(:session_new_cb)
|
|
117
|
+
@ssl_context.session_new_cb = proc { |sock, sess| @ssl_session = sess }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
verify_hostname = @ssl_context.verify_hostname
|
|
121
|
+
|
|
122
|
+
case @address
|
|
123
|
+
when Resolv::IPv4::Regex, Resolv::IPv6::Regex
|
|
124
|
+
@ssl_context.verify_hostname = false
|
|
125
|
+
else
|
|
126
|
+
ssl_host_address = @address
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
debug "starting SSL for #{conn_addr}:#{conn_port}..."
|
|
130
|
+
s = OpenSSL::SSL::SSLSocket.new(s, @ssl_context)
|
|
131
|
+
s.sync_close = true
|
|
132
|
+
s.hostname = ssl_host_address if s.respond_to?(:hostname=) && ssl_host_address
|
|
133
|
+
|
|
134
|
+
if @ssl_session &&
|
|
135
|
+
Process.clock_gettime(Process::CLOCK_REALTIME) < @ssl_session.time.to_f + @ssl_session.timeout
|
|
136
|
+
s.session = @ssl_session
|
|
137
|
+
end
|
|
138
|
+
ssl_socket_connect(s, @open_timeout)
|
|
139
|
+
if (@ssl_context.verify_mode != OpenSSL::SSL::VERIFY_NONE) && verify_hostname
|
|
140
|
+
s.post_connection_check(@address)
|
|
141
|
+
end
|
|
142
|
+
debug "SSL established, protocol: #{s.ssl_version}, cipher: #{s.cipher[0]}"
|
|
143
|
+
end
|
|
144
|
+
@socket = Net::BufferedIO.new(s, read_timeout: @read_timeout,
|
|
145
|
+
write_timeout: @write_timeout,
|
|
146
|
+
continue_timeout: @continue_timeout,
|
|
147
|
+
debug_output: @debug_output)
|
|
148
|
+
@last_communicated = nil
|
|
149
|
+
on_connect
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
if s
|
|
152
|
+
debug "Conn close because of connect error #{e}"
|
|
153
|
+
s.close
|
|
154
|
+
end
|
|
155
|
+
raise
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
class << self
|
|
160
|
+
def patch!
|
|
161
|
+
return if @patched
|
|
162
|
+
|
|
163
|
+
::Net::HTTP.prepend(Extension)
|
|
164
|
+
@patched = true
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def patched?
|
|
168
|
+
@patched == true
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyProxyHeaders
|
|
4
|
+
# RestClient integration for proxy headers support.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# response = RubyProxyHeaders::RestClient.get(
|
|
8
|
+
# 'https://example.com',
|
|
9
|
+
# proxy: 'http://user:pass@proxy:8080',
|
|
10
|
+
# proxy_headers: { 'X-ProxyMesh-Country' => 'US' }
|
|
11
|
+
# )
|
|
12
|
+
# puts response.proxy_response_headers
|
|
13
|
+
#
|
|
14
|
+
module RestClient
|
|
15
|
+
# Make a GET request with proxy headers.
|
|
16
|
+
def self.get(url, proxy:, proxy_headers: {}, **options)
|
|
17
|
+
request(:get, url, proxy: proxy, proxy_headers: proxy_headers, **options)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Make a POST request with proxy headers.
|
|
21
|
+
def self.post(url, proxy:, proxy_headers: {}, payload: nil, **options)
|
|
22
|
+
request(:post, url, proxy: proxy, proxy_headers: proxy_headers, body: payload, **options)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Make a PUT request with proxy headers.
|
|
26
|
+
def self.put(url, proxy:, proxy_headers: {}, payload: nil, **options)
|
|
27
|
+
request(:put, url, proxy: proxy, proxy_headers: proxy_headers, body: payload, **options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Make a DELETE request with proxy headers.
|
|
31
|
+
def self.delete(url, proxy:, proxy_headers: {}, **options)
|
|
32
|
+
request(:delete, url, proxy: proxy, proxy_headers: proxy_headers, **options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Make a request with proxy headers.
|
|
36
|
+
def self.request(method, url, proxy:, proxy_headers: {}, **options)
|
|
37
|
+
require 'rest-client'
|
|
38
|
+
|
|
39
|
+
uri = URI.parse(url)
|
|
40
|
+
|
|
41
|
+
# For HTTPS with proxy headers, use our core connection
|
|
42
|
+
if uri.scheme == 'https' && !proxy_headers.empty?
|
|
43
|
+
response = RubyProxyHeaders::NetHTTP.request(
|
|
44
|
+
method,
|
|
45
|
+
url,
|
|
46
|
+
proxy: proxy,
|
|
47
|
+
proxy_headers: proxy_headers,
|
|
48
|
+
headers: options[:headers],
|
|
49
|
+
body: options[:body]
|
|
50
|
+
)
|
|
51
|
+
return response
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# For HTTP or no proxy headers, use standard RestClient
|
|
55
|
+
::RestClient.proxy = proxy
|
|
56
|
+
response = ::RestClient.send(method, url, options)
|
|
57
|
+
ProxyResponse.new(response, {})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Response wrapper with proxy headers accessor
|
|
61
|
+
class ProxyResponse
|
|
62
|
+
attr_reader :proxy_response_headers
|
|
63
|
+
|
|
64
|
+
def initialize(response, proxy_headers)
|
|
65
|
+
@response = response
|
|
66
|
+
@proxy_response_headers = proxy_headers
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def code
|
|
70
|
+
@response.code
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def body
|
|
74
|
+
@response.body
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def headers
|
|
78
|
+
@response.headers
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def method_missing(method, *args, &block)
|
|
82
|
+
@response.send(method, *args, &block)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def respond_to_missing?(method, include_private = false)
|
|
86
|
+
@response.respond_to?(method, include_private) || super
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyProxyHeaders
|
|
4
|
+
# Typhoeus/Ethon integration for proxy headers support.
|
|
5
|
+
# Typhoeus wraps libcurl, which has native support for CURLOPT_PROXYHEADER.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# response = RubyProxyHeaders::Typhoeus.get(
|
|
9
|
+
# 'https://example.com',
|
|
10
|
+
# proxy: 'http://user:pass@proxy:8080',
|
|
11
|
+
# proxy_headers: { 'X-ProxyMesh-Country' => 'US' }
|
|
12
|
+
# )
|
|
13
|
+
# puts response.proxy_response_headers
|
|
14
|
+
#
|
|
15
|
+
module Typhoeus
|
|
16
|
+
# Make a GET request with proxy headers.
|
|
17
|
+
# @param url [String] Target URL
|
|
18
|
+
# @param proxy [String] Proxy URL
|
|
19
|
+
# @param proxy_headers [Hash] Custom headers to send to proxy
|
|
20
|
+
# @param options [Hash] Additional Typhoeus options
|
|
21
|
+
# @return [ProxyResponse]
|
|
22
|
+
def self.get(url, proxy:, proxy_headers: {}, **options)
|
|
23
|
+
request(:get, url, proxy: proxy, proxy_headers: proxy_headers, **options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Make a POST request with proxy headers.
|
|
27
|
+
def self.post(url, proxy:, proxy_headers: {}, body: nil, **options)
|
|
28
|
+
request(:post, url, proxy: proxy, proxy_headers: proxy_headers, body: body, **options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Make a request with proxy headers.
|
|
32
|
+
# @param method [Symbol] HTTP method
|
|
33
|
+
# @param url [String] Target URL
|
|
34
|
+
# @param proxy [String] Proxy URL
|
|
35
|
+
# @param proxy_headers [Hash] Custom headers to send to proxy
|
|
36
|
+
# @param options [Hash] Additional Typhoeus options
|
|
37
|
+
# @return [ProxyResponse]
|
|
38
|
+
def self.request(method, url, proxy:, proxy_headers: {}, **options)
|
|
39
|
+
require 'typhoeus'
|
|
40
|
+
|
|
41
|
+
uri = URI.parse(url)
|
|
42
|
+
|
|
43
|
+
# For HTTPS, we need custom handling since Typhoeus doesn't expose CURLOPT_PROXYHEADER
|
|
44
|
+
if uri.scheme == 'https' && !proxy_headers.empty?
|
|
45
|
+
# Use our core connection for now
|
|
46
|
+
# TODO: Extend Ethon to expose CURLOPT_PROXYHEADER for native support
|
|
47
|
+
response = RubyProxyHeaders::NetHTTP.request(
|
|
48
|
+
method,
|
|
49
|
+
url,
|
|
50
|
+
proxy: proxy,
|
|
51
|
+
proxy_headers: proxy_headers,
|
|
52
|
+
headers: options[:headers],
|
|
53
|
+
body: options[:body]
|
|
54
|
+
)
|
|
55
|
+
return response
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# For HTTP or no proxy headers, use standard Typhoeus
|
|
59
|
+
typhoeus_options = options.merge(
|
|
60
|
+
proxy: proxy,
|
|
61
|
+
method: method
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
response = ::Typhoeus::Request.new(url, typhoeus_options).run
|
|
65
|
+
ProxyResponse.new(response, {})
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Response wrapper with proxy headers accessor
|
|
69
|
+
class ProxyResponse
|
|
70
|
+
attr_reader :proxy_response_headers
|
|
71
|
+
|
|
72
|
+
def initialize(response, proxy_headers)
|
|
73
|
+
@response = response
|
|
74
|
+
@proxy_response_headers = proxy_headers
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def code
|
|
78
|
+
@response.code
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def body
|
|
82
|
+
@response.body
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def headers
|
|
86
|
+
@response.headers
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def success?
|
|
90
|
+
@response.success?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def method_missing(method, *args, &block)
|
|
94
|
+
@response.send(method, *args, &block)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def respond_to_missing?(method, include_private = false)
|
|
98
|
+
@response.respond_to?(method, include_private) || super
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ruby_proxy_headers/version'
|
|
4
|
+
require_relative 'ruby_proxy_headers/net_http'
|
|
5
|
+
|
|
6
|
+
module RubyProxyHeaders
|
|
7
|
+
# CONNECT response headers from the last Net::HTTP-based proxied HTTPS request on this thread.
|
|
8
|
+
def self.proxy_connect_response_headers
|
|
9
|
+
Thread.current[:ruby_proxy_headers_connect_headers]
|
|
10
|
+
end
|
|
11
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby-proxy-headers
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ProxyMesh
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-04 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: bundler
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.4'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.4'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: excon
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.4'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.4'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: faraday
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '2.14'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '2.14'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: faraday-net_http
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.4'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.4'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: httparty
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0.24'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0.24'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: mechanize
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '2.14'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '2.14'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: typhoeus
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '1.6'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '1.6'
|
|
111
|
+
description: |
|
|
112
|
+
Extensions for Ruby HTTP stacks to send custom headers on HTTPS CONNECT to a proxy
|
|
113
|
+
and read headers from the proxy CONNECT response (e.g. X-ProxyMesh-IP).
|
|
114
|
+
email: support@proxymesh.com
|
|
115
|
+
executables: []
|
|
116
|
+
extensions: []
|
|
117
|
+
extra_rdoc_files: []
|
|
118
|
+
files:
|
|
119
|
+
- DEFERRED.md
|
|
120
|
+
- IMPLEMENTATION_PRIORITY.md
|
|
121
|
+
- LIBRARY_RESEARCH.md
|
|
122
|
+
- LICENSE
|
|
123
|
+
- README.md
|
|
124
|
+
- lib/ruby_proxy_headers.rb
|
|
125
|
+
- lib/ruby_proxy_headers/connection.rb
|
|
126
|
+
- lib/ruby_proxy_headers/excon.rb
|
|
127
|
+
- lib/ruby_proxy_headers/faraday.rb
|
|
128
|
+
- lib/ruby_proxy_headers/http_gem.rb
|
|
129
|
+
- lib/ruby_proxy_headers/httparty.rb
|
|
130
|
+
- lib/ruby_proxy_headers/net_http.rb
|
|
131
|
+
- lib/ruby_proxy_headers/rest_client.rb
|
|
132
|
+
- lib/ruby_proxy_headers/typhoeus.rb
|
|
133
|
+
- lib/ruby_proxy_headers/version.rb
|
|
134
|
+
homepage: https://github.com/proxymesh/ruby-proxy-headers
|
|
135
|
+
licenses:
|
|
136
|
+
- MIT
|
|
137
|
+
metadata:
|
|
138
|
+
source_code_uri: https://github.com/proxymesh/ruby-proxy-headers
|
|
139
|
+
post_install_message:
|
|
140
|
+
rdoc_options: []
|
|
141
|
+
require_paths:
|
|
142
|
+
- lib
|
|
143
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
144
|
+
requirements:
|
|
145
|
+
- - ">="
|
|
146
|
+
- !ruby/object:Gem::Version
|
|
147
|
+
version: '3.1'
|
|
148
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
149
|
+
requirements:
|
|
150
|
+
- - ">="
|
|
151
|
+
- !ruby/object:Gem::Version
|
|
152
|
+
version: '0'
|
|
153
|
+
requirements: []
|
|
154
|
+
rubygems_version: 3.4.20
|
|
155
|
+
signing_key:
|
|
156
|
+
specification_version: 4
|
|
157
|
+
summary: Custom proxy CONNECT headers for Ruby HTTP clients (ProxyMesh, etc.)
|
|
158
|
+
test_files: []
|