snell 0.1.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/CHANGELOG.md +14 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/docs/ARCHITECTURE.md +87 -0
- data/docs/COMPATIBILITY.md +160 -0
- data/docs/PROVENANCE.md +59 -0
- data/docs/SECURITY.md +38 -0
- data/exe/snell +6 -0
- data/lib/snell/address.rb +26 -0
- data/lib/snell/cli.rb +140 -0
- data/lib/snell/client.rb +141 -0
- data/lib/snell/configuration.rb +193 -0
- data/lib/snell/crypto.rb +77 -0
- data/lib/snell/datagram.rb +14 -0
- data/lib/snell/datagram_socket.rb +68 -0
- data/lib/snell/direct_connector.rb +28 -0
- data/lib/snell/errors.rb +22 -0
- data/lib/snell/limits.rb +43 -0
- data/lib/snell/obfuscation.rb +241 -0
- data/lib/snell/protocol.rb +166 -0
- data/lib/snell/record_reader.rb +133 -0
- data/lib/snell/record_writer.rb +218 -0
- data/lib/snell/replay_cache.rb +40 -0
- data/lib/snell/server.rb +352 -0
- data/lib/snell/session.rb +84 -0
- data/lib/snell/session_pool.rb +100 -0
- data/lib/snell/socks_server.rb +371 -0
- data/lib/snell/stream.rb +203 -0
- data/lib/snell/transport.rb +140 -0
- data/lib/snell/v6/profile.rb +303 -0
- data/lib/snell/version.rb +5 -0
- data/lib/snell.rb +68 -0
- metadata +155 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snell
|
|
4
|
+
# reactor 内的连接池,每个物理会话最多有一个活跃借用者。
|
|
5
|
+
# 拨号让出执行权期间,预留项仍计入容量;只有上层确认排空且
|
|
6
|
+
# 会话健康时才能回到空闲池;此处不重试应用操作。
|
|
7
|
+
class SessionPool
|
|
8
|
+
def initialize(limits:)
|
|
9
|
+
@limits = limits
|
|
10
|
+
@sessions = []
|
|
11
|
+
@closed = false
|
|
12
|
+
@reaper = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def acquire(reuse: true)
|
|
16
|
+
raise ClosedError, "Client is closed" if @closed
|
|
17
|
+
|
|
18
|
+
expire_idle
|
|
19
|
+
session = reuse && @sessions.grep(Session).find { |candidate| candidate.state == :ready }
|
|
20
|
+
unless session
|
|
21
|
+
raise CapacityError, "Connection limit reached" if @sessions.size >= @limits[:max_connections]
|
|
22
|
+
|
|
23
|
+
# 拨号会让出执行权,因此先预留容量,防止其他任务超额接入。
|
|
24
|
+
reservation = Object.new
|
|
25
|
+
@sessions << reservation
|
|
26
|
+
begin
|
|
27
|
+
session = yield
|
|
28
|
+
if @closed
|
|
29
|
+
session.close
|
|
30
|
+
raise ClosedError, "Client closed during connection attempt"
|
|
31
|
+
end
|
|
32
|
+
@sessions[@sessions.index(reservation)] = session
|
|
33
|
+
ensure
|
|
34
|
+
@sessions.delete(reservation)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
session.activate!
|
|
38
|
+
session
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 借用者判断协议是否可复用;连接池负责空闲数量和期限限制。
|
|
42
|
+
def release(session, reusable:)
|
|
43
|
+
return if @closed || !@sessions.include?(session)
|
|
44
|
+
|
|
45
|
+
if reusable && !session.closed?
|
|
46
|
+
session.ready!
|
|
47
|
+
idle = @sessions.count { |candidate| candidate.is_a?(Session) && candidate.state == :ready }
|
|
48
|
+
discard(session) if idle > @limits[:pool_max_idle]
|
|
49
|
+
schedule_expiration
|
|
50
|
+
else
|
|
51
|
+
discard(session)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def close_idle_connections
|
|
56
|
+
@sessions.grep(Session).select { |session| session.state == :ready }.each { |session| discard(session) }
|
|
57
|
+
@reaper&.stop
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def close
|
|
61
|
+
@closed = true
|
|
62
|
+
@reaper&.stop
|
|
63
|
+
@sessions.grep(Session).each(&:close)
|
|
64
|
+
@sessions.clear
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def schedule_expiration
|
|
70
|
+
return if @reaper&.alive?
|
|
71
|
+
|
|
72
|
+
# 空闲清理任务不应阻止已完成业务的 reactor 退出。
|
|
73
|
+
@reaper = Async::Task.current.async(transient: true) do
|
|
74
|
+
until @closed
|
|
75
|
+
idle = @sessions.grep(Session).select { |session| session.state == :ready }
|
|
76
|
+
break if idle.empty?
|
|
77
|
+
|
|
78
|
+
deadline = idle.map(&:last_used).min + @limits[:pool_idle_timeout]
|
|
79
|
+
sleep([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
|
|
80
|
+
expire_idle
|
|
81
|
+
end
|
|
82
|
+
ensure
|
|
83
|
+
@reaper = nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def expire_idle
|
|
88
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
89
|
+
@sessions.grep(Session).each do |session|
|
|
90
|
+
discard(session) if session.closed? ||
|
|
91
|
+
(session.state == :ready && now - session.last_used >= @limits[:pool_idle_timeout])
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def discard(session)
|
|
96
|
+
session.close
|
|
97
|
+
@sessions.delete(session)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "io/wait"
|
|
7
|
+
|
|
8
|
+
module Snell
|
|
9
|
+
# 仅供本地使用的无认证 SOCKS5 桥接器;UDP 按 RFC 1928 编解码,
|
|
10
|
+
# 独立于 Client#open_udp 使用的 Snell 报文格式。
|
|
11
|
+
class SocksServer
|
|
12
|
+
# SOCKS UDP ASSOCIATE 允许通配地址和零端口,因此协商值
|
|
13
|
+
# 独立于要求有效目标端口的 Snell::Address。
|
|
14
|
+
Target = Data.define(:host, :port)
|
|
15
|
+
class RequestError < StandardError
|
|
16
|
+
attr_reader :reply
|
|
17
|
+
|
|
18
|
+
def initialize(reply)
|
|
19
|
+
@reply = reply
|
|
20
|
+
super("invalid SOCKS request")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :bound_address
|
|
25
|
+
|
|
26
|
+
def initialize(client:, limits: {})
|
|
27
|
+
@client = client
|
|
28
|
+
@limits = (limits.is_a?(Limits) ? limits : Limits.new(limits)).to_h
|
|
29
|
+
@sessions = {}
|
|
30
|
+
@handshakes = 0
|
|
31
|
+
@udp_count = 0
|
|
32
|
+
@tokens = @limits.fetch(:handshake_burst).to_f
|
|
33
|
+
@token_time = monotonic
|
|
34
|
+
@stopping = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run(listen:)
|
|
38
|
+
raise ArgumentError, "SOCKS listen must be loopback" unless loopback?(listen.host)
|
|
39
|
+
return if @stopping
|
|
40
|
+
|
|
41
|
+
@listener = TCPServer.new(listen.host, listen.port)
|
|
42
|
+
local = @listener.local_address
|
|
43
|
+
@bound_address = Address.new(host: local.ip_address, port: local.ip_port)
|
|
44
|
+
owner = Async::Task.current
|
|
45
|
+
@accept_task = owner.async do
|
|
46
|
+
until @stopping
|
|
47
|
+
socket = accept_socket
|
|
48
|
+
unless admit?
|
|
49
|
+
socket.close
|
|
50
|
+
sleep(0)
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
@handshakes += 1
|
|
54
|
+
start_session(socket, owner)
|
|
55
|
+
end
|
|
56
|
+
rescue IOError, SystemCallError
|
|
57
|
+
raise unless @stopping
|
|
58
|
+
end
|
|
59
|
+
@accept_task.wait
|
|
60
|
+
ensure
|
|
61
|
+
@listener&.close unless @listener&.closed?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stop(grace: @limits.fetch(:shutdown_grace))
|
|
65
|
+
@stopping = true
|
|
66
|
+
@accept_task&.stop
|
|
67
|
+
@listener&.close unless @listener&.closed?
|
|
68
|
+
deadline = monotonic + grace
|
|
69
|
+
sleep([0.01, deadline - monotonic].min) while @sessions.any? && monotonic < deadline
|
|
70
|
+
@sessions.values.compact.each(&:stop)
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def loopback?(host)
|
|
77
|
+
host == "localhost" || IPAddr.new(host).loopback?
|
|
78
|
+
rescue IPAddr::InvalidAddressError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def monotonic
|
|
83
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def start_session(socket, owner)
|
|
87
|
+
# 每个 socket 使用独立方法作用域,并在 Async 启动子任务前登记;
|
|
88
|
+
# 循环中的局部变量可能被后续接入连接的闭包共享。
|
|
89
|
+
key = Object.new
|
|
90
|
+
@sessions[key] = nil
|
|
91
|
+
child = owner.async do
|
|
92
|
+
serve(socket)
|
|
93
|
+
ensure
|
|
94
|
+
socket.close unless socket.closed?
|
|
95
|
+
@sessions.delete(key)
|
|
96
|
+
end
|
|
97
|
+
@sessions[key] = child if @sessions.key?(key)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def admit?
|
|
101
|
+
now = monotonic
|
|
102
|
+
@tokens = [@limits.fetch(:handshake_burst), @tokens + ((now - @token_time) * @limits.fetch(:handshake_rate))].min
|
|
103
|
+
@token_time = now
|
|
104
|
+
return false if @sessions.size >= @limits.fetch(:max_connections)
|
|
105
|
+
return false if @handshakes >= @limits.fetch(:handshake_concurrency) || @tokens < 1
|
|
106
|
+
|
|
107
|
+
@tokens -= 1
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def accept_socket
|
|
112
|
+
loop do
|
|
113
|
+
result = @listener.accept_nonblock(exception: false)
|
|
114
|
+
return result unless result == :wait_readable
|
|
115
|
+
|
|
116
|
+
@listener.wait_readable
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# 协商结束后释放握手名额;连接与 UDP 名额持续到转发清理完成。
|
|
121
|
+
# 超时或取消时也必须关闭已取得的 resource。
|
|
122
|
+
def serve(socket)
|
|
123
|
+
resource = nil
|
|
124
|
+
command = nil
|
|
125
|
+
replied = false
|
|
126
|
+
udp_slot = false
|
|
127
|
+
begin
|
|
128
|
+
within(@limits.fetch(:handshake_timeout)) do
|
|
129
|
+
command, target = negotiate(socket)
|
|
130
|
+
case command
|
|
131
|
+
when 1
|
|
132
|
+
raise RequestError, 8 if target.port.zero?
|
|
133
|
+
|
|
134
|
+
resource = @client.connect(target.host, target.port)
|
|
135
|
+
# Snell 首回复可能等待应用数据;此处只确认请求已发送,
|
|
136
|
+
# 目标拨号失败仍可能在随后读取时表现为连接关闭。
|
|
137
|
+
reply(socket, 0)
|
|
138
|
+
when 3
|
|
139
|
+
raise RequestError, 1 if @udp_count >= @limits.fetch(:udp_associations)
|
|
140
|
+
|
|
141
|
+
@udp_count += 1
|
|
142
|
+
udp_slot = true
|
|
143
|
+
resource = @client.open_udp
|
|
144
|
+
else
|
|
145
|
+
raise RequestError, 7
|
|
146
|
+
end
|
|
147
|
+
replied = command == 1
|
|
148
|
+
end
|
|
149
|
+
ensure
|
|
150
|
+
@handshakes -= 1
|
|
151
|
+
end
|
|
152
|
+
if command == 1
|
|
153
|
+
relay(socket, resource)
|
|
154
|
+
else
|
|
155
|
+
replied = true
|
|
156
|
+
relay_udp(socket, resource)
|
|
157
|
+
end
|
|
158
|
+
rescue RequestError => e
|
|
159
|
+
reply(socket, e.reply) unless replied
|
|
160
|
+
rescue IOError, SystemCallError
|
|
161
|
+
nil
|
|
162
|
+
rescue StandardError
|
|
163
|
+
reply(socket, 1) unless replied
|
|
164
|
+
ensure
|
|
165
|
+
resource&.close
|
|
166
|
+
@udp_count -= 1 if udp_slot
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def negotiate(socket)
|
|
170
|
+
version, count = exact(socket, 2).unpack("CC")
|
|
171
|
+
raise EOFError unless version == 5 && count.positive?
|
|
172
|
+
|
|
173
|
+
methods = exact(socket, count)
|
|
174
|
+
if methods.bytes.include?(0)
|
|
175
|
+
socket.write("\x05\x00".b)
|
|
176
|
+
else
|
|
177
|
+
socket.write("\x05\xff".b)
|
|
178
|
+
raise EOFError
|
|
179
|
+
end
|
|
180
|
+
version, command, reserved, type = exact(socket, 4).unpack("CCCC")
|
|
181
|
+
raise RequestError, 1 unless version == 5 && reserved.zero?
|
|
182
|
+
raise RequestError, 7 unless [1, 3].include?(command)
|
|
183
|
+
|
|
184
|
+
[command, read_address(socket, type)]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def read_address(source, type)
|
|
188
|
+
host = case type
|
|
189
|
+
when 1 then IPAddr.new_ntoh(exact(source, 4)).to_s
|
|
190
|
+
when 4 then IPAddr.new_ntoh(exact(source, 16)).to_s
|
|
191
|
+
when 3
|
|
192
|
+
size = exact(source, 1).unpack1("C")
|
|
193
|
+
raise RequestError, 8 if size.zero?
|
|
194
|
+
|
|
195
|
+
name = exact(source, size)
|
|
196
|
+
raise RequestError, 8 if !name.ascii_only? || name.match?(/[\x00-\x20\x7f]/)
|
|
197
|
+
|
|
198
|
+
name
|
|
199
|
+
else
|
|
200
|
+
raise RequestError, 8
|
|
201
|
+
end
|
|
202
|
+
Target.new(host: host, port: exact(source, 2).unpack1("n"))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def exact(source, size)
|
|
206
|
+
result = +"".b
|
|
207
|
+
while result.bytesize < size
|
|
208
|
+
part = source.read(size - result.bytesize)
|
|
209
|
+
raise EOFError unless part && !part.empty?
|
|
210
|
+
|
|
211
|
+
result << part
|
|
212
|
+
end
|
|
213
|
+
result
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def reply(socket, status, address = Target.new(host: "0.0.0.0", port: 0))
|
|
217
|
+
socket.write([5, status, 0].pack("CCC") + encode_address(address))
|
|
218
|
+
rescue IOError, SystemCallError
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def encode_address(address)
|
|
223
|
+
ip = begin
|
|
224
|
+
IPAddr.new(address.host)
|
|
225
|
+
rescue IPAddr::InvalidAddressError
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
host = if ip
|
|
229
|
+
[ip.ipv4? ? 1 : 4].pack("C") + ip.hton
|
|
230
|
+
else
|
|
231
|
+
raise RequestError, 8 unless address.host.bytesize.between?(1, 255)
|
|
232
|
+
|
|
233
|
+
[3, address.host.bytesize].pack("CC") + address.host.b
|
|
234
|
+
end
|
|
235
|
+
host + [address.port].pack("n")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def within(seconds, &)
|
|
239
|
+
Async::Task.current.with_timeout(seconds, &)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def copy(source, destination)
|
|
243
|
+
loop do
|
|
244
|
+
data = within(@limits.fetch(:idle_timeout)) { source.readpartial(@limits.fetch(:buffer_bytes)) }
|
|
245
|
+
break unless data && !data.empty?
|
|
246
|
+
|
|
247
|
+
within(@limits.fetch(:idle_timeout)) { destination.write(data) }
|
|
248
|
+
end
|
|
249
|
+
rescue EOFError
|
|
250
|
+
nil
|
|
251
|
+
ensure
|
|
252
|
+
begin
|
|
253
|
+
destination.close_write
|
|
254
|
+
rescue IOError, SystemCallError
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def relay(socket, upstream)
|
|
260
|
+
tasks = [[socket, upstream], [upstream, socket]].map do |source, destination|
|
|
261
|
+
Async::Task.current.async do
|
|
262
|
+
copy(source, destination)
|
|
263
|
+
rescue StandardError
|
|
264
|
+
socket.close unless socket.closed?
|
|
265
|
+
upstream.close
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
tasks.each(&:wait)
|
|
269
|
+
ensure
|
|
270
|
+
tasks&.each(&:stop)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# 先绑定 TCP 对端 IP,再固定第一个有效 UDP 报文的来源端口。
|
|
274
|
+
# 控制连接 EOF 或空闲期限到达时,结束两个转发子任务。
|
|
275
|
+
def relay_udp(control, association)
|
|
276
|
+
local = control.local_address
|
|
277
|
+
peer_ip = IPAddr.new(control.remote_address.ip_address)
|
|
278
|
+
socket = UDPSocket.new(local.afamily)
|
|
279
|
+
socket.bind(local.ip_address, 0)
|
|
280
|
+
bound = socket.local_address
|
|
281
|
+
reply(control, 0, Target.new(host: bound.ip_address, port: bound.ip_port))
|
|
282
|
+
peer = nil
|
|
283
|
+
last_activity = monotonic
|
|
284
|
+
failed = false
|
|
285
|
+
children = []
|
|
286
|
+
children << Async::Task.current.async do
|
|
287
|
+
received = 0
|
|
288
|
+
loop do
|
|
289
|
+
packet, sender = receive_datagram(socket)
|
|
290
|
+
received += 1
|
|
291
|
+
sleep(0) if (received % 32).zero?
|
|
292
|
+
next unless IPAddr.new(sender[3]) == peer_ip
|
|
293
|
+
next if peer && sender[1] != peer.fetch(:port)
|
|
294
|
+
|
|
295
|
+
parsed = decode_datagram(packet)
|
|
296
|
+
next unless parsed
|
|
297
|
+
|
|
298
|
+
address, payload = parsed
|
|
299
|
+
peer ||= { host: sender[3], port: sender[1] }
|
|
300
|
+
last_activity = monotonic
|
|
301
|
+
association.send_packet(payload, to: address)
|
|
302
|
+
end
|
|
303
|
+
rescue StandardError
|
|
304
|
+
failed = true
|
|
305
|
+
end
|
|
306
|
+
children << Async::Task.current.async do
|
|
307
|
+
received = 0
|
|
308
|
+
loop do
|
|
309
|
+
datagram = association.receive_packet
|
|
310
|
+
break unless datagram
|
|
311
|
+
|
|
312
|
+
received += 1
|
|
313
|
+
sleep(0) if (received % 32).zero?
|
|
314
|
+
next unless peer
|
|
315
|
+
|
|
316
|
+
packet = "\0\0\0".b + encode_address(datagram.address) + datagram.payload
|
|
317
|
+
next if packet.bytesize > 65_507
|
|
318
|
+
|
|
319
|
+
send_datagram(socket, packet, peer)
|
|
320
|
+
last_activity = monotonic
|
|
321
|
+
end
|
|
322
|
+
rescue StandardError
|
|
323
|
+
failed = true
|
|
324
|
+
end
|
|
325
|
+
loop do
|
|
326
|
+
remaining = @limits.fetch(:idle_timeout) - (monotonic - last_activity)
|
|
327
|
+
break if failed || remaining <= 0
|
|
328
|
+
next unless control.wait_readable([remaining, 1].min)
|
|
329
|
+
|
|
330
|
+
break if control.read_nonblock(1, exception: false).nil?
|
|
331
|
+
end
|
|
332
|
+
ensure
|
|
333
|
+
children&.each(&:stop)
|
|
334
|
+
socket&.close unless socket&.closed?
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# RFC 1928 的 RSV/FRAG 必须为零;非法或分片 UDP 报文
|
|
338
|
+
# 在固定来源端口和向上游发送之前丢弃。
|
|
339
|
+
def decode_datagram(packet)
|
|
340
|
+
return unless packet.bytesize >= 4 && packet.byteslice(0, 3) == "\0\0\0".b
|
|
341
|
+
|
|
342
|
+
input = StringIO.new(packet)
|
|
343
|
+
input.pos = 3
|
|
344
|
+
target = read_address(input, exact(input, 1).unpack1("C"))
|
|
345
|
+
return if target.port.zero?
|
|
346
|
+
|
|
347
|
+
[Address.new(host: target.host, port: target.port), input.read]
|
|
348
|
+
rescue RequestError, EOFError, ArgumentError
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def receive_datagram(socket)
|
|
353
|
+
loop do
|
|
354
|
+
result = socket.recvfrom_nonblock(65_535, exception: false)
|
|
355
|
+
return result unless result == :wait_readable
|
|
356
|
+
|
|
357
|
+
socket.wait_readable
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def send_datagram(socket, packet, peer)
|
|
362
|
+
address = Socket.sockaddr_in(peer.fetch(:port), peer.fetch(:host))
|
|
363
|
+
loop do
|
|
364
|
+
result = socket.sendmsg_nonblock(packet, 0, address, exception: false)
|
|
365
|
+
return result unless result == :wait_writable
|
|
366
|
+
|
|
367
|
+
socket.wait_writable
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
data/lib/snell/stream.rb
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/semaphore"
|
|
4
|
+
|
|
5
|
+
module Snell
|
|
6
|
+
# 采用 IO 风格的逻辑 TCP 流,允许一个读者与一个写者同时推进;
|
|
7
|
+
# 同方向操作串行执行,首次读取负责确认远端回复。
|
|
8
|
+
# 关闭时可排空数据以便复用,但不会重新发送失败的应用写入。
|
|
9
|
+
class Stream
|
|
10
|
+
def initialize(session, limits:, reusable:, release:)
|
|
11
|
+
@session = session
|
|
12
|
+
@limits = limits
|
|
13
|
+
@reusable = reusable
|
|
14
|
+
@release = release
|
|
15
|
+
@owner = Fiber.scheduler
|
|
16
|
+
@read_lock = Async::Semaphore.new(1)
|
|
17
|
+
@write_lock = Async::Semaphore.new(1)
|
|
18
|
+
@buffer = +"".b
|
|
19
|
+
@reply_received = false
|
|
20
|
+
@read_eof = false
|
|
21
|
+
@write_eof = false
|
|
22
|
+
@closing = false
|
|
23
|
+
@closed = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 指定长度时最多返回该数量的字节,EOF 返回 nil;省略长度时
|
|
27
|
+
# 收集到 EOF。超时按记录计算,不覆盖整个收集过程。
|
|
28
|
+
def read(length = nil, outbuf = nil)
|
|
29
|
+
validate_length!(length) unless length.nil?
|
|
30
|
+
check_owner!
|
|
31
|
+
check_open!
|
|
32
|
+
result = @read_lock.acquire do
|
|
33
|
+
check_open!
|
|
34
|
+
if length
|
|
35
|
+
fill_buffer while @buffer.bytesize < length && !@read_eof
|
|
36
|
+
length.zero? ? +"".b : take_buffer(length)
|
|
37
|
+
else
|
|
38
|
+
chunks = [take_buffer(@buffer.bytesize)]
|
|
39
|
+
until @read_eof
|
|
40
|
+
fill_buffer
|
|
41
|
+
chunks << take_buffer(@buffer.bytesize)
|
|
42
|
+
end
|
|
43
|
+
chunks.compact.join.b
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
outbuf&.replace(result || "")
|
|
47
|
+
result.nil? ? nil : (outbuf || result)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def readpartial(length, outbuf = nil)
|
|
51
|
+
validate_length!(length)
|
|
52
|
+
check_owner!
|
|
53
|
+
check_open!
|
|
54
|
+
result = @read_lock.acquire do
|
|
55
|
+
check_open!
|
|
56
|
+
if length.zero?
|
|
57
|
+
+"".b
|
|
58
|
+
else
|
|
59
|
+
fill_buffer while @buffer.empty? && !@read_eof
|
|
60
|
+
raise EOFError, "Stream reached EOF" if @buffer.empty?
|
|
61
|
+
|
|
62
|
+
take_buffer(length)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
outbuf ? outbuf.replace(result) : result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def write(bytes)
|
|
69
|
+
check_owner!
|
|
70
|
+
check_open!
|
|
71
|
+
bytes = String(bytes).b
|
|
72
|
+
@write_lock.acquire do
|
|
73
|
+
# 等待锁期间可能已开始关闭;此时不能访问已交给下一个借用者的会话。
|
|
74
|
+
check_open!
|
|
75
|
+
raise ClosedError, "Stream write side is closed" if @write_eof
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
@session.write_stream(bytes) unless bytes.empty?
|
|
79
|
+
rescue Error, IOError, SystemCallError
|
|
80
|
+
# 只有实际传输失败才损坏会话;拒绝过期流的操作不能关闭新租约。
|
|
81
|
+
abort_session
|
|
82
|
+
raise
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
bytes.bytesize
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def close_write
|
|
89
|
+
check_owner!
|
|
90
|
+
return if @closed || @write_eof
|
|
91
|
+
|
|
92
|
+
@write_lock.acquire do
|
|
93
|
+
next if @closed || @write_eof
|
|
94
|
+
|
|
95
|
+
@write_eof = true
|
|
96
|
+
begin
|
|
97
|
+
@session.write_eof
|
|
98
|
+
rescue Error, IOError, SystemCallError
|
|
99
|
+
# reactor 检查和等待后的关闭拒绝不应损坏其他借用者的会话。
|
|
100
|
+
abort_session
|
|
101
|
+
raise
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# 排空同时受时间与字节预算限制;清理失败时关闭物理会话,
|
|
108
|
+
# 避免将未排空或已损坏的会话归还为空闲连接。
|
|
109
|
+
def close
|
|
110
|
+
check_owner!
|
|
111
|
+
return if @closed || @closing
|
|
112
|
+
|
|
113
|
+
# 排空会让出执行权;先取得关闭权,避免另一个 Fiber 重复归还新租约。
|
|
114
|
+
# 关闭期间仍需调用 close_write,因此与最终关闭状态分别记录。
|
|
115
|
+
@closing = true
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
if @reusable && !@session.closed?
|
|
119
|
+
@session.draining!
|
|
120
|
+
Async::Task.current.with_timeout(@limits[:drain_timeout]) do
|
|
121
|
+
close_write
|
|
122
|
+
@read_lock.acquire do
|
|
123
|
+
discarded = @buffer.bytesize
|
|
124
|
+
@buffer.clear
|
|
125
|
+
until @read_eof
|
|
126
|
+
fill_buffer
|
|
127
|
+
discarded += @buffer.bytesize
|
|
128
|
+
@buffer.clear
|
|
129
|
+
raise CapacityError, "Stream drain limit reached" if discarded > @limits[:drain_max_bytes]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
@session.close
|
|
135
|
+
end
|
|
136
|
+
rescue StandardError
|
|
137
|
+
@session.close
|
|
138
|
+
ensure
|
|
139
|
+
@closed = true
|
|
140
|
+
@release.call(@session, reusable: @reusable && @read_eof && @write_eof && !@session.closed?)
|
|
141
|
+
end
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def closed?
|
|
146
|
+
@closed || @session.closed?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def inspect
|
|
150
|
+
"#<#{self.class} closed=#{closed?} read_eof=#{@read_eof} write_eof=#{@write_eof}>"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def fill_buffer
|
|
156
|
+
record = @session.read_record
|
|
157
|
+
unless @reply_received
|
|
158
|
+
raise ProtocolError, "Missing tunnel reply" if record.empty?
|
|
159
|
+
|
|
160
|
+
reply = Protocol.decode_reply(record)
|
|
161
|
+
raise RemoteError.new(reply.message.to_s, code: reply.code) if reply.kind == 2
|
|
162
|
+
raise ProtocolError, "Unexpected tunnel reply" unless reply.kind.zero?
|
|
163
|
+
|
|
164
|
+
@reply_received = true
|
|
165
|
+
record = reply.payload
|
|
166
|
+
end
|
|
167
|
+
if record.empty? && @reply_received
|
|
168
|
+
# 只有回复头而没有载荷的隧道回复,不是逻辑 EOF 标记。
|
|
169
|
+
@read_eof = true unless defined?(reply) && reply
|
|
170
|
+
else
|
|
171
|
+
@buffer << record
|
|
172
|
+
end
|
|
173
|
+
rescue EOFError
|
|
174
|
+
@read_eof = true
|
|
175
|
+
@session.close
|
|
176
|
+
rescue Error, IOError, SystemCallError
|
|
177
|
+
abort_session
|
|
178
|
+
raise
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def take_buffer(length)
|
|
182
|
+
return nil if @buffer.empty? && @read_eof && length.positive?
|
|
183
|
+
|
|
184
|
+
@buffer.slice!(0, length)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def abort_session
|
|
188
|
+
@session.close
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def check_owner!
|
|
192
|
+
raise Error, "Stream belongs to another reactor" unless Fiber.scheduler.equal?(@owner)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def check_open!
|
|
196
|
+
raise ClosedError, "Stream is closed" if @closed || @closing
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def validate_length!(length)
|
|
200
|
+
raise ArgumentError, "length must be nonnegative" unless length.is_a?(Integer) && length >= 0
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|