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.
@@ -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
@@ -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