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
data/lib/snell/limits.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snell
|
|
4
|
+
# Ruby API 使用的不可变资源预算:时间以秒计,容量以字节计;
|
|
5
|
+
# 只有 pool_max_idle 允许为零,表示不保留空闲连接。
|
|
6
|
+
class Limits
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
max_connections: 2048, handshake_concurrency: 64,
|
|
9
|
+
handshake_rate: 100, handshake_burst: 200, udp_associations: 256,
|
|
10
|
+
buffer_bytes: 131_072, dial_timeout: 10, handshake_timeout: 10,
|
|
11
|
+
idle_timeout: 300, pool_max_idle: 10, pool_idle_timeout: 180,
|
|
12
|
+
drain_timeout: 2, drain_max_bytes: 524_288, shutdown_grace: 30
|
|
13
|
+
}.freeze
|
|
14
|
+
INTEGERS = %i[max_connections handshake_concurrency handshake_burst udp_associations
|
|
15
|
+
buffer_bytes pool_max_idle drain_max_bytes].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(values = {})
|
|
18
|
+
raise ArgumentError, "limits must be a Hash" unless values.is_a?(Hash)
|
|
19
|
+
raise ArgumentError, "Invalid limit name" unless values.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
|
|
20
|
+
|
|
21
|
+
values = values.to_h.transform_keys(&:to_sym)
|
|
22
|
+
unknown = values.keys - DEFAULTS.keys
|
|
23
|
+
raise ArgumentError, "Unknown limits: #{unknown.join(', ')}" unless unknown.empty?
|
|
24
|
+
|
|
25
|
+
@values = DEFAULTS.merge(values).freeze
|
|
26
|
+
@values.each do |name, value|
|
|
27
|
+
valid = value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
|
|
28
|
+
valid &&= value.is_a?(Integer) if INTEGERS.include?(name)
|
|
29
|
+
valid ||= name == :pool_max_idle && value.is_a?(Integer) && value.zero?
|
|
30
|
+
raise ArgumentError, "Invalid limit #{name}" unless valid
|
|
31
|
+
end
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def [](name)
|
|
36
|
+
@values.fetch(name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_h
|
|
40
|
+
@values.dup
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Snell
|
|
6
|
+
# 旧版协议可选的外层混淆;HTTP/TLS 形态不提供证书认证,
|
|
7
|
+
# 也不建立安全 TLS 会话。
|
|
8
|
+
module Obfuscation
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def wrap(transport, role:, mode: nil, kind: nil, host: nil, uri: "/")
|
|
12
|
+
raise ArgumentError, "conflicting obfuscation modes" if mode && kind && mode != kind
|
|
13
|
+
|
|
14
|
+
Protocol.validate_role!(role)
|
|
15
|
+
selected = mode || kind || :none
|
|
16
|
+
case selected
|
|
17
|
+
when :none then transport
|
|
18
|
+
when :http then HTTP.new(transport, role: role, host: host || "bing.com", uri: uri)
|
|
19
|
+
when :tls then TLS.new(transport, role: role, host: host || "cloudfront.net")
|
|
20
|
+
else raise ArgumentError, "unsupported obfuscation mode"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 沿用底层传输的期限和所有权;每个方向在整个物理连接中
|
|
25
|
+
# 只发送或读取一次混淆握手。
|
|
26
|
+
class Wrapper
|
|
27
|
+
def initialize(transport, role:, host:)
|
|
28
|
+
unless host.is_a?(String) && !host.empty? && host.bytesize <= 255 && !host.match?(/[\r\n\0]/)
|
|
29
|
+
raise ArgumentError, "obfuscation host must contain 1..255 bytes without line breaks or NUL"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@transport = transport
|
|
33
|
+
@role = role
|
|
34
|
+
@host = host.b.freeze
|
|
35
|
+
@read_started = false
|
|
36
|
+
@write_started = false
|
|
37
|
+
@read_buffer = +"".b
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def with_timeout(seconds, &)
|
|
41
|
+
@transport.with_timeout(seconds, &)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def close
|
|
45
|
+
@transport.close
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def close_write
|
|
49
|
+
@transport.close_write
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def closed?
|
|
53
|
+
@transport.closed?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inspect
|
|
57
|
+
"#<#{self.class} role=#{@role}>"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def continuation(length)
|
|
63
|
+
return +"".b if length.zero?
|
|
64
|
+
|
|
65
|
+
@transport.read_exactly(length)
|
|
66
|
+
rescue EOFError
|
|
67
|
+
raise ProtocolError, "truncated obfuscation frame", cause: nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 有界的 HTTP 形态头部后直接传输 Snell 字节,不实现 WebSocket。
|
|
72
|
+
class HTTP < Wrapper
|
|
73
|
+
MAX_HEADER = 16 * 1024
|
|
74
|
+
|
|
75
|
+
def initialize(transport, role:, host:, uri:)
|
|
76
|
+
super(transport, role: role, host: host)
|
|
77
|
+
unless uri.is_a?(String) && uri.start_with?("/") && uri.bytesize <= 4096 && !uri.match?(/[\x00-\x20\x7f]/)
|
|
78
|
+
raise ArgumentError, "HTTP obfuscation URI must be an absolute path without whitespace or controls"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@uri = uri.b.freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write_all(bytes)
|
|
85
|
+
raise ArgumentError, "payload must be a String" unless bytes.is_a?(String)
|
|
86
|
+
return 0 if bytes.empty?
|
|
87
|
+
|
|
88
|
+
wire = @write_started ? bytes : header(bytes.bytesize) + bytes.b
|
|
89
|
+
@write_started = true
|
|
90
|
+
@transport.write_all(wire)
|
|
91
|
+
bytes.bytesize
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def read_exactly(length)
|
|
95
|
+
raise ArgumentError, "length must be nonnegative" unless length.is_a?(Integer) && length >= 0
|
|
96
|
+
return +"".b if length.zero?
|
|
97
|
+
|
|
98
|
+
read_header unless @read_started
|
|
99
|
+
@transport.read_exactly(length)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def header(length)
|
|
105
|
+
if @role == :client
|
|
106
|
+
"GET #{@uri} HTTP/1.1\r\nHost: #{@host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" \
|
|
107
|
+
"Content-Length: #{length}\r\nSec-WebSocket-Key: #{[SecureRandom.random_bytes(16)].pack('m0')}\r\n\r\n".b
|
|
108
|
+
else
|
|
109
|
+
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" \
|
|
110
|
+
"Sec-WebSocket-Accept: #{[SecureRandom.random_bytes(20)].pack('m0')}\r\n\r\n".b
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def read_header
|
|
115
|
+
bytes = @transport.read_exactly(1)
|
|
116
|
+
until bytes.end_with?("\r\n\r\n")
|
|
117
|
+
raise ProtocolError, "HTTP obfuscation header exceeds limit" if bytes.bytesize >= MAX_HEADER
|
|
118
|
+
|
|
119
|
+
bytes << continuation(1)
|
|
120
|
+
end
|
|
121
|
+
@read_started = true
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# 使用 TLS 形态记录传输字节,不提供 TLS 安全保证。
|
|
126
|
+
class TLS < Wrapper
|
|
127
|
+
MAX_RECORD = 16_384
|
|
128
|
+
MAX_EMPTY_RECORDS = 64
|
|
129
|
+
CIPHERS = [0xc02f, 0xc030, 0xcca8, 0xcca9, 0xc02b, 0xc02c, 0xc013, 0xc014,
|
|
130
|
+
0xc009, 0xc00a, 0x009c, 0x009d, 0x002f, 0x0035, 0x003c, 0x003d,
|
|
131
|
+
0x0067, 0x006b, 0x0033, 0x0039, 0x009e, 0x009f, 0xc027, 0xc028,
|
|
132
|
+
0xc023, 0xc024, 0x00a2, 0x00a3].pack("n*").freeze
|
|
133
|
+
|
|
134
|
+
def write_all(bytes)
|
|
135
|
+
raise ArgumentError, "payload must be a String" unless bytes.is_a?(String)
|
|
136
|
+
return 0 if bytes.empty?
|
|
137
|
+
|
|
138
|
+
wire = +"".b
|
|
139
|
+
offset = 0
|
|
140
|
+
unless @write_started
|
|
141
|
+
length = [bytes.bytesize, @role == :client ? 1024 : MAX_RECORD].min
|
|
142
|
+
first = bytes.byteslice(0, length).b
|
|
143
|
+
wire << (@role == :client ? client_hello(first) : server_hello(first))
|
|
144
|
+
offset = length
|
|
145
|
+
@write_started = true
|
|
146
|
+
end
|
|
147
|
+
while offset < bytes.bytesize
|
|
148
|
+
length = [bytes.bytesize - offset, MAX_RECORD].min
|
|
149
|
+
wire << [23, 0x0303, length].pack("Cnn") << bytes.byteslice(offset, length).b
|
|
150
|
+
offset += length
|
|
151
|
+
end
|
|
152
|
+
@transport.write_all(wire)
|
|
153
|
+
bytes.bytesize
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def read_exactly(length)
|
|
157
|
+
raise ArgumentError, "length must be nonnegative" unless length.is_a?(Integer) && length >= 0
|
|
158
|
+
return +"".b if length.zero?
|
|
159
|
+
|
|
160
|
+
empty_records = 0
|
|
161
|
+
while @read_buffer.bytesize < length
|
|
162
|
+
begin
|
|
163
|
+
part = @read_started ? read_frame : read_handshake
|
|
164
|
+
rescue EOFError
|
|
165
|
+
raise if @read_buffer.empty?
|
|
166
|
+
|
|
167
|
+
raise ProtocolError, "truncated obfuscation stream", cause: nil
|
|
168
|
+
end
|
|
169
|
+
if part.empty?
|
|
170
|
+
empty_records += 1
|
|
171
|
+
raise ProtocolError, "too many empty obfuscation frames" if empty_records > MAX_EMPTY_RECORDS
|
|
172
|
+
end
|
|
173
|
+
@read_buffer << part
|
|
174
|
+
end
|
|
175
|
+
result = @read_buffer.byteslice(0, length)
|
|
176
|
+
@read_buffer = @read_buffer.byteslice(length, @read_buffer.bytesize - length)
|
|
177
|
+
result
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def client_hello(payload)
|
|
183
|
+
record_length = @host.bytesize + payload.bytesize + 212
|
|
184
|
+
hello = [22, 0x0301, record_length].pack("Cnn")
|
|
185
|
+
hello << "\x01".b << uint24(record_length - 4) << "\x03\x03".b
|
|
186
|
+
hello << SecureRandom.random_bytes(32) << "\x20".b << SecureRandom.random_bytes(32)
|
|
187
|
+
hello << [CIPHERS.bytesize].pack("n") << CIPHERS << "\x01\x00".b
|
|
188
|
+
hello << [@host.bytesize + payload.bytesize + 79, 35, payload.bytesize].pack("n*") << payload
|
|
189
|
+
hello << [0, @host.bytesize + 5, @host.bytesize + 3, 0, @host.bytesize].pack("nnnCn") << @host
|
|
190
|
+
hello << [10, 8, 6, 29, 23, 24].pack("n*")
|
|
191
|
+
hello << [13, 14, 12, 0x0403, 0x0503, 0x0603, 0x0401, 0x0501, 0x0601].pack("n*")
|
|
192
|
+
hello << ["000b0002010000170000ff0100010000150011"].pack("H*") << ("\0".b * 17)
|
|
193
|
+
hello
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def server_hello(payload)
|
|
197
|
+
hello = [22, 0x0303, 70].pack("Cnn") + "\x02".b + uint24(66) + "\x03\x03".b
|
|
198
|
+
hello << SecureRandom.random_bytes(32) << "\x1a".b << SecureRandom.random_bytes(26)
|
|
199
|
+
hello << [0xc02f, 0, 0].pack("nCn")
|
|
200
|
+
hello << [20, 0x0303, 1, 1].pack("CnnC")
|
|
201
|
+
hello << [22, 0x0303, 16].pack("Cnn") << SecureRandom.random_bytes(16)
|
|
202
|
+
hello << [23, 0x0303, payload.bytesize].pack("Cnn") << payload
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def uint24(number)
|
|
206
|
+
[number].pack("N").byteslice(1, 3)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def read_handshake
|
|
210
|
+
prefix_length = @role == :server ? 140 : 105
|
|
211
|
+
@transport.read_exactly(1)
|
|
212
|
+
continuation(prefix_length - 1)
|
|
213
|
+
length = continuation(2).unpack1("n")
|
|
214
|
+
limit = @role == :server ? 1024 : MAX_RECORD
|
|
215
|
+
raise ProtocolError, "oversized obfuscation handshake payload" if length > limit
|
|
216
|
+
|
|
217
|
+
payload = continuation(length)
|
|
218
|
+
if @role == :server
|
|
219
|
+
name_header = continuation(9)
|
|
220
|
+
name_length = name_header.byteslice(7, 2).unpack1("n")
|
|
221
|
+
raise ProtocolError, "oversized obfuscation hostname" if name_length > 255
|
|
222
|
+
|
|
223
|
+
continuation(name_length + 66)
|
|
224
|
+
end
|
|
225
|
+
@read_started = true
|
|
226
|
+
payload
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def read_frame
|
|
230
|
+
first = @transport.read_exactly(1)
|
|
231
|
+
header = first + continuation(4)
|
|
232
|
+
type, version, length = header.unpack("Cnn")
|
|
233
|
+
unless type == 23 && version == 0x0303 && length <= MAX_RECORD
|
|
234
|
+
raise ProtocolError, "invalid obfuscation record header"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
continuation(length)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snell
|
|
4
|
+
# 请求、回复和 Snell UDP 载荷的纯编解码逻辑,不执行网络 I/O。
|
|
5
|
+
# 解码器区分非法协议数据与编码器调用参数错误。
|
|
6
|
+
module Protocol
|
|
7
|
+
COMMANDS = [0, 1, 5, 6].freeze
|
|
8
|
+
REPLIES = [0, 1, 2].freeze
|
|
9
|
+
|
|
10
|
+
# remaining 是提前发送的 TCP 数据,由 Server 解释与转发。
|
|
11
|
+
# 请求诊断不输出用户密钥或流量内容。
|
|
12
|
+
Request = Data.define(:command, :user_key, :destination, :remaining) do
|
|
13
|
+
def inspect
|
|
14
|
+
"#<#{self.class} command=#{command} destination=#{destination.inspect} remaining_bytes=#{remaining.bytesize}>"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
Reply = Data.define(:kind, :code, :message, :payload)
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def encode_request(command:, user_key: "", destination: nil)
|
|
22
|
+
raise ArgumentError, "unsupported command" unless COMMANDS.include?(command)
|
|
23
|
+
|
|
24
|
+
validate_bytes!(user_key, "user key", maximum: 255)
|
|
25
|
+
bytes = [1, command, user_key.bytesize].pack("C*") + user_key.b
|
|
26
|
+
if [1, 5].include?(command)
|
|
27
|
+
validate_address!(destination)
|
|
28
|
+
bytes << [destination.host.bytesize].pack("C") << destination.host << [destination.port].pack("n")
|
|
29
|
+
elsif destination
|
|
30
|
+
raise ArgumentError, "command does not accept a destination"
|
|
31
|
+
end
|
|
32
|
+
bytes
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def decode_request(bytes)
|
|
36
|
+
cursor = Cursor.new(bytes)
|
|
37
|
+
raise ProtocolError, "unsupported request version" unless cursor.byte == 1
|
|
38
|
+
|
|
39
|
+
command = cursor.byte
|
|
40
|
+
raise ProtocolError, "unsupported request command" unless COMMANDS.include?(command)
|
|
41
|
+
|
|
42
|
+
user_key = cursor.take(cursor.byte).freeze
|
|
43
|
+
destination = [1, 5].include?(command) ? cursor.domain_address : nil
|
|
44
|
+
Request.new(command: command, user_key: user_key, destination: destination, remaining: cursor.rest.freeze)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def encode_reply(kind:, code: nil, message: nil, payload: "")
|
|
48
|
+
raise ArgumentError, "unsupported reply kind" unless REPLIES.include?(kind)
|
|
49
|
+
|
|
50
|
+
validate_bytes!(payload, "payload")
|
|
51
|
+
bytes = [kind].pack("C")
|
|
52
|
+
if kind == 2
|
|
53
|
+
raise ArgumentError, "error code must be a byte" unless code.is_a?(Integer) && code.between?(0, 255)
|
|
54
|
+
|
|
55
|
+
validate_bytes!(message, "error message", maximum: 255)
|
|
56
|
+
bytes << [code, message.bytesize].pack("CC") << message.b
|
|
57
|
+
elsif code || message
|
|
58
|
+
raise ArgumentError, "only error replies accept a code or message"
|
|
59
|
+
end
|
|
60
|
+
bytes << payload.b
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def decode_reply(bytes)
|
|
64
|
+
cursor = Cursor.new(bytes)
|
|
65
|
+
kind = cursor.byte
|
|
66
|
+
raise ProtocolError, "unsupported reply kind" unless REPLIES.include?(kind)
|
|
67
|
+
|
|
68
|
+
code = kind == 2 ? cursor.byte : nil
|
|
69
|
+
message = kind == 2 ? cursor.take(cursor.byte).freeze : nil
|
|
70
|
+
Reply.new(kind: kind, code: code, message: message, payload: cursor.rest.freeze)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# encode_packet 的 role 表示发送方,decode_packet 的 role 表示接收方。
|
|
74
|
+
def encode_packet(payload, address, role:)
|
|
75
|
+
validate_role!(role)
|
|
76
|
+
validate_bytes!(payload, "payload")
|
|
77
|
+
validate_address!(address)
|
|
78
|
+
ip = address.ip
|
|
79
|
+
bytes = if role == :server
|
|
80
|
+
raise ArgumentError, "UDP source must be an IP address" unless ip
|
|
81
|
+
|
|
82
|
+
[ip.ipv4? ? 4 : 6].pack("C") + ip.hton
|
|
83
|
+
elsif ip
|
|
84
|
+
[1, 0, ip.ipv4? ? 4 : 6].pack("C*") + ip.hton
|
|
85
|
+
else
|
|
86
|
+
[1, address.host.bytesize].pack("CC") + address.host
|
|
87
|
+
end
|
|
88
|
+
bytes << [address.port].pack("n") << payload.b
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def decode_packet(bytes, role:)
|
|
92
|
+
validate_role!(role)
|
|
93
|
+
cursor = Cursor.new(bytes)
|
|
94
|
+
if role == :server
|
|
95
|
+
raise ProtocolError, "unsupported UDP command" unless cursor.byte == 1
|
|
96
|
+
|
|
97
|
+
length = cursor.byte
|
|
98
|
+
address = length.zero? ? cursor.ip_address : cursor.domain_address(length)
|
|
99
|
+
else
|
|
100
|
+
address = cursor.ip_address
|
|
101
|
+
end
|
|
102
|
+
Datagram.new(payload: cursor.rest, address: address)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_role!(role)
|
|
106
|
+
raise ArgumentError, "role must be :client or :server" unless %i[client server].include?(role)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_address!(address)
|
|
110
|
+
raise ArgumentError, "destination must be a Snell::Address" unless address.is_a?(Address)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_bytes!(bytes, name, maximum: nil)
|
|
114
|
+
raise ArgumentError, "#{name} must be a String" unless bytes.is_a?(String)
|
|
115
|
+
raise ArgumentError, "#{name} exceeds #{maximum} bytes" if maximum && bytes.bytesize > maximum
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# 游标推进前先校验边界,截断输入不会产生不完整字段。
|
|
119
|
+
# 地址校验错误在此转换为协议异常。
|
|
120
|
+
class Cursor
|
|
121
|
+
def initialize(bytes)
|
|
122
|
+
raise ArgumentError, "wire data must be a String" unless bytes.is_a?(String)
|
|
123
|
+
|
|
124
|
+
@bytes = bytes.b
|
|
125
|
+
@offset = 0
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def take(length)
|
|
129
|
+
raise ProtocolError, "truncated protocol message" if length > @bytes.bytesize - @offset
|
|
130
|
+
|
|
131
|
+
value = @bytes.byteslice(@offset, length)
|
|
132
|
+
@offset += length
|
|
133
|
+
value
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def byte
|
|
137
|
+
take(1).getbyte(0)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def rest
|
|
141
|
+
take(@bytes.bytesize - @offset)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def domain_address(length = byte)
|
|
145
|
+
make_address(take(length), take(2).unpack1("n"))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ip_address
|
|
149
|
+
family = byte
|
|
150
|
+
length = { 4 => 4, 6 => 16 }[family]
|
|
151
|
+
raise ProtocolError, "unsupported IP address family" unless length
|
|
152
|
+
|
|
153
|
+
make_address(IPAddr.new_ntoh(take(length)).to_s, take(2).unpack1("n"))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def make_address(host, port)
|
|
159
|
+
Address.new(host: host, port: port)
|
|
160
|
+
rescue ArgumentError
|
|
161
|
+
raise ProtocolError, "invalid protocol address", cause: nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
private_constant :Cursor
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "v6/profile"
|
|
4
|
+
|
|
5
|
+
module Snell
|
|
6
|
+
# 一个物理连接的入站方向;逻辑 EOF 与顺序复用不重置
|
|
7
|
+
# 密码器或序列状态,上层必须串行化同方向的读取。
|
|
8
|
+
# 任何未完成或被拒绝的记录都会永久停用该读取器。
|
|
9
|
+
class RecordReader
|
|
10
|
+
attr_reader :salt
|
|
11
|
+
# 完整首条记录认证成功后才公开盐值;raw 模式没有认证盐值。
|
|
12
|
+
alias authenticated_salt salt
|
|
13
|
+
|
|
14
|
+
def initialize(transport, psk:, version:, role:, mode: :default)
|
|
15
|
+
validate_profile!(psk, version, mode, role)
|
|
16
|
+
@transport = transport
|
|
17
|
+
@psk = psk.b.freeze
|
|
18
|
+
@version = version
|
|
19
|
+
@mode = mode
|
|
20
|
+
@role = role
|
|
21
|
+
@cipher = nil
|
|
22
|
+
@salt = nil
|
|
23
|
+
@failed = false
|
|
24
|
+
@sequence = 0
|
|
25
|
+
@profile = V6::Profile.new(psk) if version == 6 && mode == :default
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# 空载荷表示逻辑 EOF;记录开始前的物理 EOF 使用 EOFError。
|
|
29
|
+
# 记录开始后的截断使用 ProtocolError,不视为正常结束。
|
|
30
|
+
def read_record
|
|
31
|
+
completed = false
|
|
32
|
+
raise ProtocolError, "record reader is no longer usable" if @failed
|
|
33
|
+
|
|
34
|
+
first = @transport.read_exactly(1)
|
|
35
|
+
payload = read_started_record(first)
|
|
36
|
+
completed = true
|
|
37
|
+
payload
|
|
38
|
+
ensure
|
|
39
|
+
@failed = true unless completed
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inspect
|
|
43
|
+
"#<#{self.class} version=#{@version} mode=#{@mode} role=#{@role}>"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def read_started_record(first)
|
|
49
|
+
return read_shaped_record(first) if @profile
|
|
50
|
+
|
|
51
|
+
return decode_record(first + continuation(6), encrypted: false) if @mode == :raw
|
|
52
|
+
|
|
53
|
+
if @cipher
|
|
54
|
+
header = first + continuation(22)
|
|
55
|
+
else
|
|
56
|
+
candidate_salt = first + continuation(15)
|
|
57
|
+
@cipher = Crypto::Cipher.new(Crypto.derive_key(@psk, candidate_salt))
|
|
58
|
+
header = continuation(23)
|
|
59
|
+
end
|
|
60
|
+
payload = decode_record(@cipher.decrypt(header), encrypted: true)
|
|
61
|
+
@salt = candidate_salt.freeze if candidate_salt
|
|
62
|
+
payload
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def read_shaped_record(first)
|
|
66
|
+
if @cipher
|
|
67
|
+
prefix = first + continuation(@profile.prefix_length(@sequence) - 1)
|
|
68
|
+
else
|
|
69
|
+
block = first + continuation(15 + @profile.salt_prefix_length)
|
|
70
|
+
candidate_salt = @profile.extract_salt(block)
|
|
71
|
+
@cipher = Crypto::Cipher.new(Crypto.derive_key(@psk, candidate_salt))
|
|
72
|
+
prefix = continuation(@profile.prefix_length(@sequence))
|
|
73
|
+
end
|
|
74
|
+
header = @cipher.decrypt(continuation(23), aad: prefix)
|
|
75
|
+
type, _reserved, padding_length, payload_length = header.unpack("Cnnn")
|
|
76
|
+
raise ProtocolError, "unsupported record type" unless type == 4
|
|
77
|
+
|
|
78
|
+
padding = continuation(padding_length)
|
|
79
|
+
payload = +"".b
|
|
80
|
+
if payload_length.positive?
|
|
81
|
+
payload = continuation(payload_length + Crypto::TAG_BYTES)
|
|
82
|
+
@profile.mix!(padding, payload, @sequence)
|
|
83
|
+
payload = @cipher.decrypt(payload, aad: padding)
|
|
84
|
+
end
|
|
85
|
+
@salt = candidate_salt.freeze if candidate_salt
|
|
86
|
+
@sequence = (@sequence + 1) & 0xffff_ffff
|
|
87
|
+
payload
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def decode_record(header, encrypted:)
|
|
91
|
+
type, reserved, padding_length, payload_length = header.unpack("Cnnn")
|
|
92
|
+
raise ProtocolError, "unsupported record type" unless type == 4
|
|
93
|
+
if @version == 6 && (reserved.nonzero? || padding_length.nonzero?)
|
|
94
|
+
raise ProtocolError, "invalid version 6 record header"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# 旧版混合规则先从 padding 恢复被交换的密文字节,再验证标签。
|
|
98
|
+
# 该格式中未参与混合的 padding 不在认证范围内。
|
|
99
|
+
padding = continuation(padding_length)
|
|
100
|
+
return +"".b if payload_length.zero?
|
|
101
|
+
|
|
102
|
+
payload = continuation(payload_length + (encrypted ? Crypto::TAG_BYTES : 0))
|
|
103
|
+
return payload unless encrypted
|
|
104
|
+
|
|
105
|
+
[padding.bytesize, payload.bytesize].min.times do |index|
|
|
106
|
+
next if index.odd?
|
|
107
|
+
|
|
108
|
+
payload.setbyte(index, padding.getbyte(index))
|
|
109
|
+
end
|
|
110
|
+
@cipher.decrypt(payload)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def continuation(length)
|
|
114
|
+
return +"".b if length.zero?
|
|
115
|
+
|
|
116
|
+
@transport.read_exactly(length)
|
|
117
|
+
rescue EOFError
|
|
118
|
+
raise ProtocolError, "truncated record", cause: nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_profile!(psk, version, mode, role)
|
|
122
|
+
raise ArgumentError, "psk must be a nonempty String" unless psk.is_a?(String) && !psk.empty?
|
|
123
|
+
raise ArgumentError, "unsupported Snell version" unless [4, 5, 6].include?(version)
|
|
124
|
+
raise ArgumentError, "unsupported record mode" unless %i[default unshaped raw].include?(mode)
|
|
125
|
+
raise ArgumentError, "record mode requires version 6" if version != 6 && mode != :default
|
|
126
|
+
if version == 6 && !psk.bytesize.between?(12, 255)
|
|
127
|
+
raise ArgumentError, "version 6 psk must contain 12 to 255 bytes"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
Protocol.validate_role!(role)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|