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.
data/lib/snell/cli.rb ADDED
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Snell
6
+ # 进程边界,负责配置校验、退出码和脱敏诊断。
7
+ # 信号处理器通过管道与 reactor 任务通信。
8
+ class CLI
9
+ USAGE = "Usage: snell {client|server|check} --config FILE\n snell version\n"
10
+
11
+ def self.run(argv = ARGV, out: $stdout, err: $stderr)
12
+ new(out: out, err: err).run(argv)
13
+ end
14
+
15
+ def initialize(out:, err:)
16
+ @out = out
17
+ @err = err
18
+ end
19
+
20
+ def run(arguments)
21
+ argv = arguments.dup
22
+ command = argv.shift
23
+ if %w[--help -h help].include?(command) && argv.empty?
24
+ @out.write(USAGE)
25
+ return 0
26
+ end
27
+ if %w[version --version -v].include?(command) && argv.empty?
28
+ @out.puts(Snell::VERSION)
29
+ return 0
30
+ end
31
+ raise Configuration::Error, "a supported command is required" unless %w[client server check].include?(command)
32
+
33
+ config_path = nil
34
+ help = false
35
+ OptionParser.new do |parser|
36
+ parser.on("--config FILE") do |path|
37
+ raise Configuration::Error, "--config may only be specified once" if config_path
38
+
39
+ config_path = path
40
+ end
41
+ parser.on("-h", "--help") { help = true }
42
+ end.parse!(argv)
43
+ raise Configuration::Error, "unexpected command arguments" unless argv.empty?
44
+
45
+ if help
46
+ @out.write(USAGE)
47
+ return 0
48
+ end
49
+ raise Configuration::Error, "--config FILE is required" unless config_path
50
+
51
+ config = Configuration.load(config_path, expected_type: command == "check" ? nil : command)
52
+ if command == "check"
53
+ @out.puts("Configuration valid (#{config.type}, version #{config.version})")
54
+ return 0
55
+ end
56
+
57
+ run_service(config)
58
+ rescue OptionParser::ParseError
59
+ @err.puts("snell: invalid command options")
60
+ @err.write(USAGE)
61
+ 2
62
+ rescue Configuration::Error => e
63
+ @err.puts("snell: #{e.message}")
64
+ 2
65
+ rescue Interrupt
66
+ 130
67
+ rescue StandardError => e
68
+ @err.puts("snell: operational failure (#{e.class})")
69
+ 1
70
+ end
71
+
72
+ private
73
+
74
+ def run_service(config)
75
+ status = 0
76
+ failure = nil
77
+ reader, writer = IO.pipe
78
+ previous = install_signals(writer)
79
+ Snell.run do
80
+ signal_received = false
81
+ client = Client.new(**config.runtime_options) if config.type == "client"
82
+ service = client ? SocksServer.new(client: client, limits: config.limits) : Server.new(**config.runtime_options)
83
+ runner = Async::Task.current.async do
84
+ service.run(listen: config.listen)
85
+ rescue StandardError => e
86
+ failure = e
87
+ end
88
+ watcher = Async::Task.current.async do
89
+ signal = reader.read(1)
90
+ signal_received = !signal.nil?
91
+ status = 130 if signal == "I"
92
+ service.stop(grace: config.limits.fetch(:shutdown_grace)) if signal
93
+ runner.stop if signal
94
+ end
95
+ begin
96
+ runner.wait
97
+ watcher.wait if signal_received
98
+ ensure
99
+ watcher.stop
100
+ service.stop(grace: 0)
101
+ runner.stop
102
+ client&.close
103
+ report_shutdown(service)
104
+ end
105
+ rescue StandardError => e
106
+ # 在此接收服务错误,避免 Async 输出自己的异常详情;
107
+ # 命令边界只输出包含错误类型的一条诊断。
108
+ failure = e
109
+ end
110
+ raise failure if failure
111
+
112
+ status
113
+ ensure
114
+ previous&.each { |signal, handler| Signal.trap(signal, handler) }
115
+ reader&.close
116
+ writer&.close
117
+ end
118
+
119
+ def report_shutdown(service)
120
+ return unless service.respond_to?(:stats)
121
+
122
+ fields = %i[accepted rejected completed failed reused active_connections handshakes udp_associations]
123
+ counters = service.stats.slice(*fields).select { |_key, value| value.is_a?(Integer) && value >= 0 }
124
+ @err.puts(JSON.generate(event: "shutdown", stats: counters))
125
+ end
126
+
127
+ # 信号处理器只写一个字节;调度与清理在监听 Fiber 内执行,
128
+ # 不在 Ruby 信号处理上下文中调用。
129
+ def install_signals(writer)
130
+ { "INT" => "I", "TERM" => "T" }.to_h do |signal, byte|
131
+ previous = Signal.trap(signal) do
132
+ writer.write_nonblock(byte, exception: false)
133
+ rescue IOError, SystemCallError
134
+ nil
135
+ end
136
+ [signal, previous]
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snell
4
+ # 由 reactor 管理的协议客户端,拥有 TCP 连接池和专用 UDP/ping
5
+ # 会话;返回的流与关联由调用者关闭,块形式则自动清理。
6
+ class Client
7
+ def initialize(server:, psk:, version: 4, mode: :default, user_key: "", reuse: true,
8
+ obfs: :none, obfs_host: nil, obfs_uri: "/", limits: {})
9
+ @owner = Snell.reactor!
10
+ raise ArgumentError, "psk must be a nonempty String" unless psk.is_a?(String) && !psk.empty?
11
+ raise ArgumentError, "user_key must be a String" unless user_key.is_a?(String)
12
+
13
+ @server = Snell.address(server)
14
+ @psk = String(psk).dup.freeze
15
+ @version = version
16
+ @mode = Snell.mode(mode)
17
+ @user_key = String(user_key).b.dup.freeze
18
+ @reuse = reuse
19
+ @obfs = Snell.obfs(obfs)
20
+ @obfs_host = obfs_host
21
+ @obfs_uri = obfs_uri
22
+ @limits = limits.is_a?(Limits) ? limits : Limits.new(limits)
23
+ @pool = SessionPool.new(limits: @limits)
24
+ @connector = DirectConnector.new
25
+ @udp_count = 0
26
+ validate!
27
+ end
28
+
29
+ # 发送请求后返回,不等待目标连接确认。
30
+ # Stream 首次读取解析回复;块形式无论成功失败都会关闭流。
31
+ def connect(host, port, early_data: nil)
32
+ check_owner!
33
+ destination = Address.new(host: host, port: port)
34
+ raise ArgumentError, "early_data must be a String" unless early_data.nil? || early_data.is_a?(String)
35
+
36
+ request = Protocol.encode_request(command: 5, user_key: @user_key, destination: destination)
37
+ session = acquire
38
+ session.transport.with_timeout(@limits[:handshake_timeout]) do
39
+ session.write_request(request, early_data: early_data || "".b)
40
+ end
41
+ stream = Stream.new(session, limits: @limits, reusable: @reuse, release: @pool.method(:release))
42
+ return stream unless block_given?
43
+
44
+ begin
45
+ yield stream
46
+ ensure
47
+ stream.close
48
+ end
49
+ ensure
50
+ @pool.release(session, reusable: false) if session && !stream
51
+ end
52
+
53
+ # 在可能让出执行权的操作前预留关联容量;构造失败回退计数,
54
+ # 成功返回的 socket 在关闭时释放预留项。
55
+ def open_udp
56
+ check_owner!
57
+ raise CapacityError, "UDP association limit reached" if @udp_count >= @limits[:udp_associations]
58
+
59
+ @udp_count += 1
60
+ counted = true
61
+ session = acquire(reuse: false)
62
+ socket = DatagramSocket.new(session, user_key: @user_key, limits: @limits, release: lambda { |owned|
63
+ @pool.release(owned, reusable: false)
64
+ @udp_count -= 1 if counted
65
+ })
66
+ return socket unless block_given?
67
+
68
+ begin
69
+ yield socket
70
+ ensure
71
+ socket.close
72
+ end
73
+ ensure
74
+ unless socket
75
+ @pool.release(session, reusable: false) if session
76
+ @udp_count -= 1 if counted
77
+ end
78
+ end
79
+
80
+ # 仅测量协议可达性,不证明用户授权或目标访问权限。
81
+ def ping
82
+ check_owner!
83
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
+ session = acquire(reuse: false)
85
+ session.transport.with_timeout(@limits[:handshake_timeout]) do
86
+ session.write_record(Protocol.encode_request(command: 0, user_key: ""))
87
+ reply = Protocol.decode_reply(session.read_record)
88
+ raise ProtocolError, "Expected pong reply" unless reply.kind == 1
89
+ end
90
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
91
+ ensure
92
+ @pool.release(session, reusable: false) if session
93
+ end
94
+
95
+ def close_idle_connections
96
+ check_owner!
97
+ @pool.close_idle_connections
98
+ end
99
+
100
+ def close
101
+ check_owner!
102
+ @pool.close
103
+ end
104
+
105
+ def inspect
106
+ "#<#{self.class} version=#{@version} mode=#{@mode}>"
107
+ end
108
+
109
+ private
110
+
111
+ def acquire(reuse: @reuse)
112
+ @pool.acquire(reuse: reuse) do
113
+ socket = @connector.connect(@server, timeout: @limits[:dial_timeout])
114
+ begin
115
+ transport = Transport.new(socket, timeout: @limits[:idle_timeout])
116
+ transport = Obfuscation.wrap(transport, mode: @obfs, role: :client, host: @obfs_host, uri: @obfs_uri)
117
+ session = Session.new(transport, psk: @psk, version: @version, mode: @mode, role: :client, limits: @limits)
118
+ ensure
119
+ socket.close unless session
120
+ end
121
+ end
122
+ end
123
+
124
+ def check_owner!
125
+ raise Error, "Client belongs to another reactor" unless Fiber.scheduler.equal?(@owner)
126
+ end
127
+
128
+ def validate!
129
+ raise ArgumentError, "Client version must be 4 or 6" unless [4, 6].include?(@version)
130
+ raise ArgumentError, "Invalid mode" unless %i[default unshaped raw].include?(@mode)
131
+ raise ArgumentError, "Mode requires version 6" unless @version == 6 || @mode == :default
132
+ raise ArgumentError, "Invalid obfuscation" unless %i[none http tls].include?(@obfs)
133
+ raise ArgumentError, "Obfuscation is unavailable with version 6" if @version == 6 && @obfs != :none
134
+ if @version == 6 && !@psk.bytesize.between?(12, 255)
135
+ raise ArgumentError, "version 6 psk must contain 12 to 255 bytes"
136
+ end
137
+ raise ArgumentError, "user_key is too long" if @user_key.bytesize > 255
138
+ raise ArgumentError, "reuse must be boolean" unless [true, false].include?(@reuse)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ipaddr"
5
+
6
+ module Snell
7
+ # 严格配置边界;诊断不包含密钥或配置原文。
8
+ class Configuration
9
+ class Error < ArgumentError; end
10
+
11
+ # JSON.parse 逐字段调用 []=,在重复键覆盖角色、密钥或
12
+ # 资源预算之前拒绝输入。
13
+ class JSONObject < Hash
14
+ def []=(key, value)
15
+ raise Error, "duplicate JSON field" if key?(key)
16
+
17
+ super
18
+ end
19
+ end
20
+
21
+ DEFAULT_LIMITS = {
22
+ max_connections: 2048, handshake_concurrency: 64, handshake_rate: 100,
23
+ handshake_burst: 200, udp_associations: 256, buffer_bytes: 131_072,
24
+ dial_timeout: 10, handshake_timeout: 10, idle_timeout: 300,
25
+ pool_max_idle: 10, pool_idle_timeout: 180, drain_timeout: 2,
26
+ drain_max_bytes: 524_288, shutdown_grace: 30
27
+ }.freeze
28
+ INTEGER_LIMITS = %i[max_connections handshake_concurrency handshake_burst
29
+ udp_associations buffer_bytes pool_max_idle drain_max_bytes].freeze
30
+ FIELDS = %w[type listen server psk user_key users version mode obfs obfs_host obfs_uri reuse limits].freeze
31
+
32
+ attr_reader :type, :listen, :server, :psk, :user_key, :users, :version,
33
+ :mode, :obfs, :obfs_host, :obfs_uri, :reuse, :limits
34
+
35
+ def self.load(path, expected_type: nil)
36
+ raw = File.read(path, encoding: Encoding::UTF_8)
37
+ new(JSON.parse(raw, object_class: JSONObject), expected_type: expected_type)
38
+ rescue JSON::ParserError, EncodingError
39
+ raise Error, "configuration must contain valid UTF-8 JSON"
40
+ rescue SystemCallError, IOError
41
+ raise Error, "cannot read configuration file"
42
+ end
43
+
44
+ def initialize(values, expected_type: nil)
45
+ object!(values, "configuration", FIELDS)
46
+ @type = enum!(values.fetch("type", nil), "type", %w[client server])
47
+ raise Error, "configuration type does not match command" if expected_type && @type != expected_type
48
+
49
+ validate_role_fields!(values)
50
+ @version = values.fetch("version", @type == "client" ? 4 : 5)
51
+ versions = @type == "client" ? [4, 6] : [5, 6]
52
+ unless @version.is_a?(Integer) && versions.include?(@version)
53
+ raise Error,
54
+ "version is not supported for this type"
55
+ end
56
+
57
+ @mode = enum!(values.fetch("mode", "default"), "mode", %w[default unshaped unsafe-raw])
58
+ @obfs = enum!(values.fetch("obfs", "none"), "obfs", %w[none http tls])
59
+ raise Error, "mode requires version 6" if @version != 6 && @mode != "default"
60
+ raise Error, "obfs is unavailable with version 6" if @version == 6 && @obfs != "none"
61
+
62
+ @psk = string!(values.fetch("psk", nil), "psk", min: @version == 6 ? 12 : 1,
63
+ max: @version == 6 ? 255 : nil)
64
+ @user_key = values.key?("user_key") ? string!(values["user_key"], "user_key", max: 255) : nil
65
+ @users = parse_users(values)
66
+ @listen = endpoint!(values.fetch("listen", @type == "client" ? "127.0.0.1:1080" : nil), "listen")
67
+ validate_loopback!(@listen.host) if @type == "client"
68
+ @server = endpoint!(values.fetch("server", nil), "server") if @type == "client"
69
+ @reuse = values.fetch("reuse", true)
70
+ raise Error, "reuse must be boolean" unless [true, false].include?(@reuse)
71
+
72
+ @obfs_host = if values.key?("obfs_host")
73
+ string!(values["obfs_host"], "obfs_host", min: 1, max: 255)
74
+ else
75
+ "www.example.com"
76
+ end
77
+ @obfs_uri = values.key?("obfs_uri") ? string!(values["obfs_uri"], "obfs_uri", min: 1, max: 2048) : "/"
78
+ raise Error, "obfs_host contains invalid characters" if @obfs_host.match?(/[\x00-\x20\x7f]/)
79
+ unless @obfs_uri.start_with?("/") && !@obfs_uri.match?(/[\x00-\x20\x7f]/)
80
+ raise Error, "obfs_uri must be an absolute request path without whitespace"
81
+ end
82
+
83
+ @limits = parse_limits(values.fetch("limits", {}))
84
+ freeze
85
+ end
86
+
87
+ # 将已校验的 JSON 角色映射为构造参数,保留 reuse 的 false,
88
+ # 并区分未配置 users 与非法的空用户数组。
89
+ def runtime_options
90
+ options = { psk: @psk, version: @version, mode: @mode, obfs: @obfs,
91
+ obfs_host: @obfs_host, obfs_uri: @obfs_uri, limits: @limits }
92
+ if @type == "client"
93
+ options.merge(server: @server, user_key: @user_key || "", reuse: @reuse)
94
+ else
95
+ options.merge(users: @users)
96
+ end
97
+ end
98
+
99
+ def inspect
100
+ "#<#{self.class} type=#{@type.inspect} version=#{@version.inspect} secrets=[REDACTED]>"
101
+ end
102
+
103
+ private
104
+
105
+ def object!(value, field, allowed)
106
+ raise Error, "#{field} must be an object" unless value.is_a?(Hash)
107
+ raise Error, "#{field} contains unknown fields" unless (value.keys - allowed).empty?
108
+ end
109
+
110
+ def string!(value, field, min: 0, max: nil)
111
+ unless value.is_a?(String) && value.encoding.ascii_compatible? && value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
112
+ raise Error, "#{field} must be a UTF-8 string"
113
+ end
114
+ raise Error, "#{field} has invalid byte length" if value.bytesize < min || (max && value.bytesize > max)
115
+
116
+ value.dup.freeze
117
+ end
118
+
119
+ def enum!(value, field, allowed)
120
+ raise Error, "#{field} has an unsupported value" unless value.is_a?(String) && allowed.include?(value)
121
+
122
+ value.dup.freeze
123
+ end
124
+
125
+ def validate_role_fields!(values)
126
+ forbidden = @type == "client" ? %w[users] : %w[server user_key reuse]
127
+ raise Error, "configuration contains fields for the other type" if values.keys.intersect?(forbidden)
128
+ end
129
+
130
+ def endpoint!(value, field)
131
+ value = string!(value, field, min: 1)
132
+ match = /\A(?:\[([^\]]+)\]|([^:\s]+)):(\d+)\z/.match(value)
133
+ raise Error, "#{field} must be host:port (IPv6 requires brackets)" unless match
134
+
135
+ host = match[1] || match[2]
136
+ port = Integer(match[3], 10)
137
+ raise Error, "#{field} port must be between 1 and 65535" unless (1..65_535).cover?(port)
138
+
139
+ if match[1]
140
+ begin
141
+ raise Error, "#{field} brackets require IPv6" unless IPAddr.new(host).ipv6?
142
+ rescue IPAddr::InvalidAddressError
143
+ raise Error, "#{field} contains invalid IPv6"
144
+ end
145
+ end
146
+ raise Error, "#{field} contains invalid host" if host.match?(%r{[\x00-\x20\x7f/]}) || host.bytesize > 255
147
+
148
+ Address.new(host: host, port: port)
149
+ end
150
+
151
+ def validate_loopback!(host)
152
+ return if host == "localhost"
153
+
154
+ raise Error, "client listen must be a loopback address" unless IPAddr.new(host).loopback?
155
+ rescue IPAddr::InvalidAddressError
156
+ raise Error, "client listen must be a loopback address"
157
+ end
158
+
159
+ def parse_users(values)
160
+ return nil unless values.key?("users")
161
+
162
+ entries = values["users"]
163
+ raise Error, "users must be a nonempty array" unless entries.is_a?(Array) && !entries.empty?
164
+
165
+ keys = {}
166
+ entries.map do |entry|
167
+ object!(entry, "users entry", %w[name user_key])
168
+ name = string!(entry.fetch("name", nil), "users name", min: 1, max: 255)
169
+ key = string!(entry.fetch("user_key", nil), "users user_key", min: 1, max: 255)
170
+ raise Error, "users contains duplicate user keys" if keys[key]
171
+
172
+ keys[key] = true
173
+ { name: name, user_key: key }.freeze
174
+ end.freeze
175
+ end
176
+
177
+ def parse_limits(values)
178
+ object!(values, "limits", DEFAULT_LIMITS.keys.map(&:to_s))
179
+ overrides = values.to_h do |key, value|
180
+ name = key.to_sym
181
+ valid_type = INTEGER_LIMITS.include?(name) ? value.is_a?(Integer) : value.is_a?(Numeric)
182
+ valid = valid_type && value.finite? && (name == :pool_max_idle ? value >= 0 : value.positive?)
183
+ unless valid
184
+ raise Error,
185
+ "limits #{key} must be a valid positive #{INTEGER_LIMITS.include?(name) ? 'integer' : 'number'}"
186
+ end
187
+
188
+ [name, value]
189
+ end
190
+ DEFAULT_LIMITS.merge(overrides).freeze
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "rbnacl"
5
+
6
+ module Snell
7
+ # 协议加密原语;KDF 参数是互通所需的固定值,
8
+ # 不作为通用密码存储策略;调用者应提供高熵 PSK。
9
+ module Crypto
10
+ SALT_BYTES = 16
11
+ TAG_BYTES = 16
12
+ NONCE_BYTES = 12
13
+
14
+ module_function
15
+
16
+ def derive_key(psk, salt)
17
+ raise ArgumentError, "psk must be a nonempty String" unless psk.is_a?(String) && !psk.empty?
18
+ raise ArgumentError, "salt must contain 16 bytes" unless salt.is_a?(String) && salt.bytesize == SALT_BYTES
19
+
20
+ RbNaCl::PasswordHash::Argon2.new(3, 8192, 32).digest(psk.b, salt.b, :argon2id).byteslice(0, 16)
21
+ end
22
+
23
+ def blake2b256(bytes)
24
+ raise ArgumentError, "input must be a String" unless bytes.is_a?(String)
25
+
26
+ RbNaCl::Hash.blake2b(bytes.b, digest_size: 32)
27
+ end
28
+
29
+ # 同一实例在物理连接的整个生命周期内只用于一个加密方向。
30
+ class Cipher
31
+ def initialize(key)
32
+ @key = key.b.freeze
33
+ @counter = 0
34
+ end
35
+
36
+ def encrypt(plaintext, aad: "".b)
37
+ cipher = build(:encrypt, aad)
38
+ result = (plaintext.empty? ? +"".b : cipher.update(plaintext)) + cipher.final
39
+ result + cipher.auth_tag(TAG_BYTES)
40
+ end
41
+
42
+ # 只有 final 验证认证标签后才返回明文。
43
+ # 失败的尝试也会消耗 nonce,记录层因此必须停用该读取器。
44
+ def decrypt(ciphertext, aad: "".b)
45
+ raise AuthenticationError, "truncated authentication tag" if ciphertext.bytesize < TAG_BYTES
46
+
47
+ cipher = build(:decrypt, aad)
48
+ cipher.auth_tag = ciphertext.byteslice(-TAG_BYTES, TAG_BYTES)
49
+ body = ciphertext.byteslice(0, ciphertext.bytesize - TAG_BYTES)
50
+ result = body.empty? ? +"".b : cipher.update(body)
51
+ result + cipher.final
52
+ rescue OpenSSL::Cipher::CipherError
53
+ raise AuthenticationError, "record authentication failed", cause: nil
54
+ end
55
+
56
+ def inspect
57
+ "#<#{self.class}>"
58
+ end
59
+
60
+ private
61
+
62
+ def build(operation, aad)
63
+ raise ProtocolError, "nonce space exhausted" if @counter >= (1 << 96)
64
+
65
+ # 96 位协议 nonce 显式采用小端序,不依赖宿主字节序。
66
+ nonce = [@counter & 0xffff_ffff_ffff_ffff, @counter >> 64].pack("Q<V")
67
+ @counter += 1
68
+ cipher = OpenSSL::Cipher.new("aes-128-gcm")
69
+ cipher.public_send(operation)
70
+ cipher.key = @key
71
+ cipher.iv = nonce
72
+ cipher.auth_data = aad
73
+ cipher
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snell
4
+ # 持有不可变的载荷副本及经过校验的目的地址或来源地址。
5
+ # Data 默认 inspect 包含载荷字节,不应直接用于流量日志。
6
+ Datagram = Data.define(:payload, :address) do
7
+ def initialize(payload:, address:)
8
+ raise ArgumentError, "payload must be a String" unless payload.is_a?(String)
9
+ raise ArgumentError, "address must be a Snell::Address" unless address.is_a?(Address)
10
+
11
+ super(payload: payload.b.freeze, address: address)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snell
4
+ # 专用的 UDP over TCP 关联,不参与 TCP 空闲连接复用。
5
+ # 构造时等待远端确认,每个报文占用一条记录。
6
+ # 空 UDP 载荷仍合法,因为地址头使协议记录保持非空。
7
+ class DatagramSocket
8
+ def initialize(session, user_key:, limits:, release:)
9
+ @session = session
10
+ @release = release
11
+ @owner = Fiber.scheduler
12
+ @read_lock = Async::Semaphore.new(1)
13
+ @write_lock = Async::Semaphore.new(1)
14
+ @closed = false
15
+ @session.transport.with_timeout(limits[:handshake_timeout]) do
16
+ @session.write_record(Protocol.encode_request(command: 6, user_key: user_key))
17
+ response = Protocol.decode_reply(@session.read_record)
18
+ raise RemoteError.new(response.message.to_s, code: response.code) if response.kind == 2
19
+ raise ProtocolError, "Expected UDP tunnel reply" unless response.kind.zero? && response.payload.empty?
20
+ end
21
+ end
22
+
23
+ def send_packet(payload, to:)
24
+ check_open!
25
+ packet = Protocol.encode_packet(payload, Snell.address(to), role: :client)
26
+ raise ArgumentError, "Datagram exceeds record capacity" if packet.bytesize > @session.writer.max_payload
27
+
28
+ @write_lock.acquire { @session.write_record(packet) }
29
+ payload.bytesize
30
+ rescue Error, IOError, SystemCallError
31
+ close
32
+ raise
33
+ end
34
+
35
+ def receive_packet
36
+ check_open!
37
+ @read_lock.acquire { Protocol.decode_packet(@session.read_record, role: :client) }
38
+ rescue Error, IOError, SystemCallError
39
+ close
40
+ raise
41
+ end
42
+
43
+ def close
44
+ check_owner!
45
+ return if @closed
46
+
47
+ @closed = true
48
+ @session.close
49
+ @release.call(@session)
50
+ nil
51
+ end
52
+
53
+ def closed?
54
+ @closed || @session.closed?
55
+ end
56
+
57
+ private
58
+
59
+ def check_open!
60
+ check_owner!
61
+ raise ClosedError, "UDP association is closed" if closed?
62
+ end
63
+
64
+ def check_owner!
65
+ raise Error, "UDP association belongs to another reactor" unless Fiber.scheduler.equal?(@owner)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/endpoint/host_endpoint"
4
+
5
+ module Snell
6
+ # 默认网络边界,可通过 Server#initialize 注入替代策略。
7
+ # connect 将 socket 所有权交给调用者;udp 只解析 Addrinfo。
8
+ # 目标访问策略由注入的连接器或部署网络负责。
9
+ class DirectConnector
10
+ def connect(address, timeout: 10)
11
+ task = Async::Task.current
12
+ task.with_timeout(timeout) do
13
+ IO::Endpoint.tcp(address.host, address.port).connect
14
+ end
15
+ rescue Async::TimeoutError
16
+ raise TimeoutError, "Connection attempt timed out"
17
+ end
18
+
19
+ # 选择一个已解析的监听地址,绑定后的 socket 由调用者负责关闭。
20
+ def bind(address)
21
+ IO::Endpoint.tcp(address.host, address.port).each.first.bind.first
22
+ end
23
+
24
+ def udp(address)
25
+ Addrinfo.getaddrinfo(address.host, address.port, nil, :DGRAM).first
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snell
4
+ # 运行时与协议错误归入 Error;Ruby API 参数非法时使用
5
+ # ArgumentError,物理连接的正常读结束保留标准 EOFError 契约。
6
+ class Error < StandardError; end
7
+ class ProtocolError < Error; end
8
+ class AuthenticationError < ProtocolError; end
9
+ class TimeoutError < Error; end
10
+ class ClosedError < Error; end
11
+ class CapacityError < Error; end
12
+
13
+ # 分别保留远端协议错误码与可读消息,便于调用者判断失败原因。
14
+ class RemoteError < Error
15
+ attr_reader :code
16
+
17
+ def initialize(message = "Remote operation failed", code: nil)
18
+ @code = code
19
+ super(message)
20
+ end
21
+ end
22
+ end