Skip to content

Commit fe7097c

Browse files
committed
feat: add websocket proxy support
1 parent ea3a451 commit fe7097c

File tree

6 files changed

+203
-57
lines changed

6 files changed

+203
-57
lines changed

shard.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: connect-proxy
2-
version: 1.3.0
2+
version: 1.4.0
33
license: MIT
44
crystal: ">= 0.36.1"
55

spec/connect_spec.cr

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require "spec"
22
require "../src/connect-proxy"
3+
require "./proxy_server"
34

45
describe ConnectProxy do
56
it "connect to a website and get a response" do
@@ -13,10 +14,11 @@ describe ConnectProxy do
1314
it "connect to a website and get a response using explicit proxy" do
1415
host = URI.parse("https://github.com/")
1516
client = ConnectProxy::HTTPClient.new(host, ignore_env: true)
16-
proxy = ConnectProxy.new("51.38.71.101", 8080)
17+
proxy = ConnectProxy.new("localhost", 22222)
1718
client.set_proxy(proxy)
1819
response = client.exec("GET", "/")
1920
response.success?.should eq(true)
21+
client.close
2022
end
2123

2224
it "connect to a website with CRL checks disabled" do
@@ -25,20 +27,39 @@ describe ConnectProxy do
2527

2628
host = URI.parse("https://github.com/")
2729
client = ConnectProxy::HTTPClient.new(host, ignore_env: true)
28-
proxy = ConnectProxy.new("51.38.71.101", 8080)
30+
proxy = ConnectProxy.new("localhost", 22222)
2931
client.set_proxy(proxy)
3032
response = client.exec("GET", "/")
3133
response.success?.should eq(true)
34+
client.close
3235
end
3336

3437
it "connect to a website with TLS disabled" do
3538
ConnectProxy.verify_tls = false
3639

3740
host = URI.parse("https://github.com/")
3841
client = ConnectProxy::HTTPClient.new(host, ignore_env: true)
39-
proxy = ConnectProxy.new("51.38.71.101", 8080)
42+
proxy = ConnectProxy.new("localhost", 22222)
4043
client.set_proxy(proxy)
4144
response = client.exec("GET", "/")
4245
response.success?.should eq(true)
46+
client.close
47+
end
48+
49+
it "connect to a websocket using explicit proxy" do
50+
received = ""
51+
host = URI.parse("wss://echo.websocket.org/")
52+
proxy = ConnectProxy.new("localhost", 22222)
53+
54+
ws = ConnectProxy::WebSocket.new(host, proxy: proxy)
55+
ws.on_message do |msg|
56+
puts msg
57+
received = msg
58+
ws.close
59+
end
60+
ws.send "test"
61+
ws.run
62+
63+
received.should eq("test")
4364
end
4465
end

spec/proxy_server.cr

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
require "socket"
2+
3+
spawn do
4+
server = TCPServer.new(22222)
5+
loop do
6+
socket = server.accept
7+
request = socket.gets("\r\n\r\n").not_nil!
8+
details = request.split(" ", 3)[1].split(":")
9+
host = details[0]
10+
port = details[1].to_i
11+
12+
puts "connecting to #{host} #{port}..."
13+
client = TCPSocket.new(host, port)
14+
puts "proxy connection established"
15+
16+
socket << "HTTP/1.1 200 OK\r\n\r\n"
17+
18+
spawn do
19+
begin
20+
raw_data = Bytes.new(2048)
21+
while !client.closed?
22+
bytes_read = client.read(raw_data)
23+
break if bytes_read.zero? # IO was closed
24+
socket.write raw_data[0, bytes_read].dup
25+
end
26+
rescue IO::Error
27+
ensure
28+
socket.close
29+
end
30+
end
31+
32+
begin
33+
out_data = Bytes.new(2048)
34+
while !socket.closed?
35+
read = socket.read(out_data)
36+
break if read.zero? # IO was closed
37+
client.write out_data[0, read].dup
38+
end
39+
rescue IO::Error
40+
ensure
41+
client.close
42+
end
43+
end
44+
end

src/connect-proxy.cr

Lines changed: 17 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,19 @@ class ConnectProxy
9191

9292
if resp[:code]? == 200
9393
if tls
94-
tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host)
95-
socket = tls_socket
94+
if tls.is_a?(Bool) # true, but we want to get rid of the union
95+
context = OpenSSL::SSL::Context::Client.new
96+
else
97+
context = tls
98+
end
99+
100+
if !ConnectProxy.verify_tls
101+
context.verify_mode = OpenSSL::SSL::VerifyMode::NONE
102+
elsif ConnectProxy.disable_crl_checks
103+
context.add_x509_verify_flags OpenSSL::SSL::X509VerifyFlags::IGNORE_CRITICAL
104+
end
105+
106+
socket = OpenSSL::SSL::Socket::Client.new(socket, context: context, sync_close: true, hostname: host)
96107
end
97108

98109
socket
@@ -119,59 +130,12 @@ class ConnectProxy
119130
resp[:code] = code.to_i
120131
resp[:reason] = reason
121132
resp[:headers] = headers
122-
rescue
133+
rescue error
134+
raise IO::Error.new("parsing proxy initialization", cause: error)
123135
end
124136

125137
resp
126138
end
127-
128-
class HTTPClient < ::HTTP::Client
129-
def self.new(uri : URI, tls = nil, ignore_env = false)
130-
inst = super(uri, tls)
131-
if !ignore_env && ConnectProxy.behind_proxy?
132-
inst.set_proxy ConnectProxy.new(*ConnectProxy.parse_proxy_url)
133-
end
134-
135-
inst
136-
end
137-
138-
def self.new(uri : URI, tls = nil, ignore_env = false)
139-
yield new(uri, tls, ignore_env)
140-
end
141-
142-
def set_proxy(proxy : ConnectProxy = nil)
143-
socket = {% if compare_versions(Crystal::VERSION, "0.36.0") < 0 %} @socket {% else %} @io {% end %}
144-
return if socket && !socket.closed?
145-
146-
if tls = @tls
147-
if !ConnectProxy.verify_tls
148-
tls.verify_mode = OpenSSL::SSL::VerifyMode::NONE
149-
elsif ConnectProxy.disable_crl_checks
150-
tls.add_x509_verify_flags OpenSSL::SSL::X509VerifyFlags::IGNORE_CRITICAL
151-
end
152-
end
153-
154-
{% if compare_versions(Crystal::VERSION, "0.36.0") < 0 %}
155-
begin
156-
@socket = proxy.open(@host, @port, @tls, **proxy_connection_options)
157-
rescue IO::Error
158-
@socket = nil
159-
end
160-
{% else %}
161-
begin
162-
@io = proxy.open(@host, @port, @tls, **proxy_connection_options)
163-
rescue IO::Error
164-
@io = nil
165-
end
166-
{% end %}
167-
end
168-
169-
def proxy_connection_options
170-
{
171-
dns_timeout: @dns_timeout,
172-
connect_timeout: @connect_timeout,
173-
read_timeout: @read_timeout,
174-
}
175-
end
176-
end
177139
end
140+
141+
require "./connect-proxy/*"

src/connect-proxy/http_client.cr

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
require "../connect-proxy"
2+
3+
class ConnectProxy::HTTPClient < ::HTTP::Client
4+
def self.new(uri : URI, tls = nil, ignore_env = false)
5+
inst = super(uri, tls)
6+
if !ignore_env && ConnectProxy.behind_proxy?
7+
inst.set_proxy ConnectProxy.new(*ConnectProxy.parse_proxy_url)
8+
end
9+
10+
inst
11+
end
12+
13+
def self.new(uri : URI, tls = nil, ignore_env = false)
14+
yield new(uri, tls, ignore_env)
15+
end
16+
17+
def set_proxy(proxy : ConnectProxy = nil)
18+
socket = {% if compare_versions(Crystal::VERSION, "0.36.0") < 0 %} @socket {% else %} @io {% end %}
19+
return if socket && !socket.closed?
20+
21+
{% if compare_versions(Crystal::VERSION, "0.36.0") < 0 %}
22+
begin
23+
@socket = proxy.open(@host, @port, @tls, **proxy_connection_options)
24+
rescue IO::Error
25+
@socket = nil
26+
end
27+
{% else %}
28+
begin
29+
@io = proxy.open(@host, @port, @tls, **proxy_connection_options)
30+
rescue IO::Error
31+
@io = nil
32+
end
33+
{% end %}
34+
end
35+
36+
def proxy_connection_options
37+
{
38+
dns_timeout: @dns_timeout,
39+
connect_timeout: @connect_timeout,
40+
read_timeout: @read_timeout,
41+
}
42+
end
43+
end

src/connect-proxy/websocket.cr

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
require "../connect-proxy"
2+
3+
# alternative initialization
4+
class HTTP::WebSocket::Protocol
5+
def self.new(socket, host : String, path : String, port, headers = HTTP::Headers.new)
6+
begin
7+
random_key = Base64.strict_encode(StaticArray(UInt8, 16).new { rand(256).to_u8 })
8+
9+
headers["Host"] = "#{host}:#{port}"
10+
headers["Connection"] = "Upgrade"
11+
headers["Upgrade"] = "websocket"
12+
headers["Sec-WebSocket-Version"] = VERSION
13+
headers["Sec-WebSocket-Key"] = random_key
14+
15+
path = "/" if path.empty?
16+
handshake = HTTP::Request.new("GET", path, headers)
17+
handshake.to_io(socket)
18+
socket.flush
19+
20+
handshake_response = HTTP::Client::Response.from_io(socket, ignore_body: true)
21+
unless handshake_response.status.switching_protocols?
22+
raise Socket::Error.new("Handshake got denied. Status code was #{handshake_response.status.code}.")
23+
end
24+
25+
challenge_response = Protocol.key_challenge(random_key)
26+
unless handshake_response.headers["Sec-WebSocket-Accept"]? == challenge_response
27+
raise Socket::Error.new("Handshake got denied. Server did not verify WebSocket challenge.")
28+
end
29+
rescue exc
30+
socket.close
31+
raise exc
32+
end
33+
34+
new(socket, masked: true)
35+
end
36+
end
37+
38+
class ConnectProxy::WebSocket < ::HTTP::WebSocket
39+
def self.new(
40+
uri : URI | String,
41+
headers = HTTP::Headers.new,
42+
proxy : ConnectProxy? = nil
43+
)
44+
uri = URI.parse(uri) if uri.is_a?(String)
45+
46+
if (host = uri.hostname) && (path = uri.request_target)
47+
tls = uri.scheme.in?("https", "wss")
48+
return new(host, path, uri.port, tls, headers, proxy)
49+
end
50+
51+
raise ArgumentError.new("No host or path specified which are required.")
52+
end
53+
54+
def self.new(
55+
host : String,
56+
path : String,
57+
port = nil,
58+
tls : HTTP::Client::TLSContext = nil,
59+
headers = HTTP::Headers.new,
60+
proxy : ConnectProxy? = nil
61+
)
62+
if proxy.nil? && ConnectProxy.behind_proxy?
63+
proxy = ConnectProxy.new(*ConnectProxy.parse_proxy_url)
64+
end
65+
66+
if proxy
67+
port ||= tls ? 443 : 80
68+
socket = proxy.open(host, port, tls)
69+
new(HTTP::WebSocket::Protocol.new(socket, host, path, port, headers))
70+
else
71+
new(HTTP::WebSocket::Protocol.new(host, path, port, tls, headers))
72+
end
73+
end
74+
end

0 commit comments

Comments
 (0)