feat: add support for SOCKS5 proxy (#5865)

This commit is contained in:
Émilien (perso)
2026-08-03 22:43:18 +02:00
committed by GitHub
parent cb88a805e5
commit 1a3cb60282
5 changed files with 509 additions and 7 deletions

View File

@@ -0,0 +1,243 @@
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