mirror of
https://github.com/iv-org/invidious.git
synced 2026-08-17 05:20:51 -05:00
244 lines
7.3 KiB
Crystal
244 lines
7.3 KiB
Crystal
require "http/client"
|
|
require "socket"
|
|
require "openssl"
|
|
require "spectator"
|
|
require "../../src/invidious/yt_backend/socks_proxy"
|
|
|
|
# A minimal in-process SOCKS5 server used to assert exactly what bytes our
|
|
# client emits (auth negotiation, CONNECT address type, target host/port) and
|
|
# that the returned IO is a usable tunnel. It handles a single connection.
|
|
class MockSocksServer
|
|
record Captured,
|
|
methods : Array(UInt8),
|
|
username : String?,
|
|
password : String?,
|
|
atyp : UInt8,
|
|
address : Bytes,
|
|
port : UInt16
|
|
|
|
# Every server registers itself so specs can close all of them in after_each,
|
|
# even when an assertion fails before an explicit close.
|
|
@@instances = [] of MockSocksServer
|
|
|
|
def self.close_all
|
|
@@instances.each(&.close)
|
|
@@instances.clear
|
|
end
|
|
|
|
getter port : Int32
|
|
|
|
def initialize(@require_auth : Bool = false,
|
|
@valid_user : String? = nil,
|
|
@valid_pass : String? = nil,
|
|
@echo : Bool = false,
|
|
@http_reply : String? = nil)
|
|
@server = TCPServer.new("127.0.0.1", 0)
|
|
@port = @server.local_address.port
|
|
@captured = Channel(Captured | Exception).new(1)
|
|
@@instances << self
|
|
spawn run
|
|
end
|
|
|
|
# Blocks until the handshake completed, returning what the server observed.
|
|
def wait : Captured
|
|
result = @captured.receive
|
|
raise result if result.is_a?(Exception)
|
|
result
|
|
end
|
|
|
|
def close
|
|
@server.close
|
|
end
|
|
|
|
private def run
|
|
socket = @server.accept
|
|
begin
|
|
captured = handshake(socket)
|
|
@captured.send(captured)
|
|
|
|
if reply = @http_reply
|
|
# Drain the request headers, then send a canned HTTP response.
|
|
while (line = socket.gets) && line != ""
|
|
end
|
|
socket << reply
|
|
socket.flush
|
|
elsif @echo
|
|
if line = socket.gets
|
|
socket << "PONG:#{line}\n"
|
|
socket.flush
|
|
end
|
|
end
|
|
rescue ex
|
|
@captured.send(ex)
|
|
ensure
|
|
socket.close
|
|
end
|
|
end
|
|
|
|
private def handshake(io : IO) : Captured
|
|
raise "unexpected version" unless io.read_byte == 0x05_u8
|
|
nmethods = io.read_byte.not_nil!
|
|
method_bytes = Bytes.new(nmethods)
|
|
io.read_fully(method_bytes)
|
|
methods = method_bytes.to_a
|
|
|
|
username = nil
|
|
password = nil
|
|
|
|
if @require_auth
|
|
unless methods.includes?(0x02_u8)
|
|
io.write(Bytes[0x05_u8, 0xFF_u8]); io.flush
|
|
raise "no acceptable auth methods offered"
|
|
end
|
|
|
|
io.write(Bytes[0x05_u8, 0x02_u8]); io.flush
|
|
|
|
raise "unexpected auth version" unless io.read_byte == 0x01_u8
|
|
ulen = io.read_byte.not_nil!
|
|
ubuf = Bytes.new(ulen); io.read_fully(ubuf); username = String.new(ubuf)
|
|
plen = io.read_byte.not_nil!
|
|
pbuf = Bytes.new(plen); io.read_fully(pbuf); password = String.new(pbuf)
|
|
|
|
ok = username == @valid_user && password == @valid_pass
|
|
io.write(Bytes[0x01_u8, ok ? 0x00_u8 : 0x01_u8]); io.flush
|
|
raise "authentication rejected" unless ok
|
|
else
|
|
io.write(Bytes[0x05_u8, 0x00_u8]); io.flush
|
|
end
|
|
|
|
raise "unexpected request version" unless io.read_byte == 0x05_u8
|
|
raise "expected CONNECT command" unless io.read_byte == 0x01_u8
|
|
io.read_byte # RSV
|
|
atyp = io.read_byte.not_nil!
|
|
|
|
address =
|
|
case atyp
|
|
when 0x01_u8
|
|
buf = Bytes.new(4); io.read_fully(buf); buf
|
|
when 0x04_u8
|
|
buf = Bytes.new(16); io.read_fully(buf); buf
|
|
when 0x03_u8
|
|
dlen = io.read_byte.not_nil!
|
|
buf = Bytes.new(dlen); io.read_fully(buf); buf
|
|
else
|
|
raise "unknown address type"
|
|
end
|
|
|
|
port_bytes = Bytes.new(2); io.read_fully(port_bytes)
|
|
port = IO::ByteFormat::BigEndian.decode(UInt16, port_bytes)
|
|
|
|
# Reply: success, BND.ADDR/PORT = 0.0.0.0:0
|
|
io.write(Bytes[0x05_u8, 0x00_u8, 0x00_u8, 0x01_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8])
|
|
io.flush
|
|
|
|
Captured.new(methods, username, password, atyp, address, port)
|
|
end
|
|
end
|
|
|
|
Spectator.describe SOCKS5::ProxyClient do
|
|
after_each { MockSocksServer.close_all }
|
|
|
|
it "sends a hostname target as a domain-type address (ATYP 0x03) for proxy-side resolution" do
|
|
server = MockSocksServer.new
|
|
client = SOCKS5::ProxyClient.new("127.0.0.1", server.port)
|
|
|
|
io = client.open("www.youtube.com", 443)
|
|
captured = server.wait
|
|
|
|
expect(captured.methods).to eq([0x00_u8]) # only no-auth offered
|
|
expect(captured.atyp).to eq(0x03_u8)
|
|
expect(String.new(captured.address)).to eq("www.youtube.com")
|
|
expect(captured.port).to eq(443_u16)
|
|
|
|
io.close
|
|
end
|
|
|
|
it "encodes an IPv4 literal target as ATYP 0x01" do
|
|
server = MockSocksServer.new
|
|
client = SOCKS5::ProxyClient.new("127.0.0.1", server.port)
|
|
|
|
io = client.open("142.250.72.174", 80)
|
|
captured = server.wait
|
|
|
|
expect(captured.atyp).to eq(0x01_u8)
|
|
expect(captured.address.to_a).to eq([142_u8, 250_u8, 72_u8, 174_u8])
|
|
expect(captured.port).to eq(80_u16)
|
|
|
|
io.close
|
|
end
|
|
|
|
it "encodes an IPv6 literal target as ATYP 0x04 (with :: zero-compression)" do
|
|
server = MockSocksServer.new
|
|
client = SOCKS5::ProxyClient.new("127.0.0.1", server.port)
|
|
|
|
io = client.open("2607:f8b0::200e", 443)
|
|
captured = server.wait
|
|
|
|
expected = Bytes[0x26, 0x07, 0xf8, 0xb0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x20, 0x0e]
|
|
expect(captured.atyp).to eq(0x04_u8)
|
|
expect(captured.address.to_a).to eq(expected.to_a)
|
|
expect(captured.port).to eq(443_u16)
|
|
|
|
io.close
|
|
end
|
|
|
|
it "offers username/password auth and authenticates (RFC 1929)" do
|
|
server = MockSocksServer.new(require_auth: true, valid_user: "alice", valid_pass: "s3cret")
|
|
client = SOCKS5::ProxyClient.new("127.0.0.1", server.port, username: "alice", password: "s3cret")
|
|
|
|
io = client.open("example.com", 80)
|
|
captured = server.wait
|
|
|
|
expect(captured.methods.includes?(0x02_u8)).to be_true
|
|
expect(captured.username).to eq("alice")
|
|
expect(captured.password).to eq("s3cret")
|
|
|
|
io.close
|
|
end
|
|
|
|
it "raises when the proxy rejects the supplied credentials" do
|
|
server = MockSocksServer.new(require_auth: true, valid_user: "alice", valid_pass: "s3cret")
|
|
client = SOCKS5::ProxyClient.new("127.0.0.1", server.port, username: "alice", password: "wrong")
|
|
|
|
expect { client.open("example.com", 80) }.to raise_error(SOCKS5::Error, /authentication failed/)
|
|
end
|
|
|
|
it "raises when the proxy requires auth but no credentials are configured" do
|
|
server = MockSocksServer.new(require_auth: true, valid_user: "alice", valid_pass: "s3cret")
|
|
client = SOCKS5::ProxyClient.new("127.0.0.1", server.port)
|
|
|
|
expect { client.open("example.com", 80) }.to raise_error(SOCKS5::Error)
|
|
end
|
|
|
|
it "returns a usable tunnel IO after the handshake" do
|
|
server = MockSocksServer.new(echo: true)
|
|
client = SOCKS5::ProxyClient.new("127.0.0.1", server.port)
|
|
|
|
io = client.open("example.com", 80)
|
|
server.wait
|
|
|
|
io << "hi\n"
|
|
io.flush
|
|
expect(io.gets).to eq("PONG:hi")
|
|
|
|
io.close
|
|
end
|
|
|
|
it "drives a real HTTP::Client request through the SOCKS tunnel via #socks_proxy=" do
|
|
server = MockSocksServer.new(http_reply: "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
|
|
|
client = HTTP::Client.new("example.com", 80)
|
|
client.socks_proxy = SOCKS5::ProxyClient.new("127.0.0.1", server.port)
|
|
|
|
response = client.get("/")
|
|
captured = server.wait
|
|
|
|
expect(captured.atyp).to eq(0x03_u8)
|
|
expect(String.new(captured.address)).to eq("example.com")
|
|
expect(captured.port).to eq(80_u16)
|
|
expect(response.status_code).to eq(204)
|
|
|
|
client.close
|
|
end
|
|
end
|