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/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
|
data/lib/snell/client.rb
ADDED
|
@@ -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
|
data/lib/snell/crypto.rb
ADDED
|
@@ -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
|
data/lib/snell/errors.rb
ADDED
|
@@ -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
|