anytls 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 +11 -0
- data/LICENSE +21 -0
- data/README.md +143 -0
- data/bin/anytls-client +7 -0
- data/bin/anytls-server +7 -0
- data/docs/ARCHITECTURE.md +105 -0
- data/docs/SOURCES.md +15 -0
- data/docs/VALIDATION.md +100 -0
- data/docs/validation/runtime-ruby34.json +8 -0
- data/docs/validation/runtime-ruby40.json +8 -0
- data/docs/validation/stress-c1.json +25 -0
- data/docs/validation/stress-c128.json +26 -0
- data/docs/validation/stress-c32.json +26 -0
- data/docs/validation/stress-sequential-10000.json +26 -0
- data/lib/anytls/address.rb +53 -0
- data/lib/anytls/buffered_io.rb +185 -0
- data/lib/anytls/byte_buffer.rb +61 -0
- data/lib/anytls/cli/client_listener.rb +91 -0
- data/lib/anytls/cli/options.rb +160 -0
- data/lib/anytls/cli/runner.rb +89 -0
- data/lib/anytls/cli/shutdown.rb +36 -0
- data/lib/anytls/client.rb +129 -0
- data/lib/anytls/connection_uri.rb +96 -0
- data/lib/anytls/deadline.rb +34 -0
- data/lib/anytls/dialer.rb +70 -0
- data/lib/anytls/errors.rb +23 -0
- data/lib/anytls/inbound/http_proxy.rb +224 -0
- data/lib/anytls/inbound/mixed.rb +60 -0
- data/lib/anytls/inbound/socks4.rb +55 -0
- data/lib/anytls/inbound/socks5.rb +138 -0
- data/lib/anytls/io_helpers.rb +87 -0
- data/lib/anytls/log.rb +15 -0
- data/lib/anytls/padding/scheme.rb +121 -0
- data/lib/anytls/padding/writer.rb +55 -0
- data/lib/anytls/protocol/authentication.rb +44 -0
- data/lib/anytls/protocol/decoder.rb +39 -0
- data/lib/anytls/protocol/frame.rb +40 -0
- data/lib/anytls/protocol/settings.rb +53 -0
- data/lib/anytls/protocol/socks_address.rb +59 -0
- data/lib/anytls/relay.rb +49 -0
- data/lib/anytls/server.rb +220 -0
- data/lib/anytls/session.rb +593 -0
- data/lib/anytls/session_pool.rb +166 -0
- data/lib/anytls/stream.rb +151 -0
- data/lib/anytls/tls_config.rb +76 -0
- data/lib/anytls/transport/tls_connection.rb +117 -0
- data/lib/anytls/uot/connection.rb +122 -0
- data/lib/anytls/uot/server.rb +97 -0
- data/lib/anytls/version.rb +6 -0
- data/lib/anytls.rb +40 -0
- metadata +203 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyTLS
|
|
4
|
+
# 握手解析器和转发器共用的小型预读缓冲;不暴露 to_io,
|
|
5
|
+
# 防止 IO 优化绕过已缓冲的字节。
|
|
6
|
+
class BufferedIO
|
|
7
|
+
attr_reader :timeout
|
|
8
|
+
|
|
9
|
+
def initialize(io, timeout: nil)
|
|
10
|
+
@io = io
|
|
11
|
+
self.timeout = timeout
|
|
12
|
+
@buffer = +"".b
|
|
13
|
+
@line_budget = nil
|
|
14
|
+
@track_trailers = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# 在协议阶段边界重新设定绝对读取预算;握手完成后清除预算,
|
|
18
|
+
# 允许已建立的隧道持续空闲。
|
|
19
|
+
def timeout=(value)
|
|
20
|
+
@timeout = value
|
|
21
|
+
@deadline = value && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + value)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def peek(length)
|
|
25
|
+
fill(length)
|
|
26
|
+
@buffer.byteslice(0, length)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def read(length = nil, outbuf = nil, timeout: @timeout)
|
|
30
|
+
raise ArgumentError, "negative length" if length&.negative?
|
|
31
|
+
|
|
32
|
+
data = +"".b
|
|
33
|
+
if length
|
|
34
|
+
data << readpartial(length - data.bytesize, timeout: timeout) while data.bytesize < length
|
|
35
|
+
else
|
|
36
|
+
loop { data << readpartial(16_384, timeout: timeout) }
|
|
37
|
+
end
|
|
38
|
+
outbuf ? outbuf.replace(data) : data
|
|
39
|
+
rescue EOFError
|
|
40
|
+
if data.empty? && length != 0
|
|
41
|
+
outbuf&.clear
|
|
42
|
+
return nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
outbuf ? outbuf.replace(data) : data
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def readpartial(length, outbuf = nil, timeout: @timeout)
|
|
49
|
+
raise ArgumentError, "negative length" if length.negative?
|
|
50
|
+
return outbuf ? outbuf.replace("") : +"".b if length.zero?
|
|
51
|
+
|
|
52
|
+
data = if @buffer.empty?
|
|
53
|
+
read_underlying(length, timeout)
|
|
54
|
+
else
|
|
55
|
+
@buffer.slice!(0, length)
|
|
56
|
+
end
|
|
57
|
+
raise EOFError if data.nil? || data.empty?
|
|
58
|
+
|
|
59
|
+
outbuf ? outbuf.replace(data) : data
|
|
60
|
+
rescue EOFError
|
|
61
|
+
outbuf&.clear
|
|
62
|
+
raise
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def gets(separator = "\n", limit = 8192)
|
|
66
|
+
raise ArgumentError, "separator must not be empty" if separator.nil? || separator.empty?
|
|
67
|
+
|
|
68
|
+
loop do
|
|
69
|
+
if (index = @buffer.index(separator))
|
|
70
|
+
size = index + separator.bytesize
|
|
71
|
+
raise BufferLimitExceeded, "HTTP line exceeds limit" if size > limit
|
|
72
|
+
|
|
73
|
+
return account_line(@buffer.slice!(0, size))
|
|
74
|
+
end
|
|
75
|
+
raise BufferLimitExceeded, "HTTP line exceeds limit" if @buffer.bytesize >= limit
|
|
76
|
+
|
|
77
|
+
@buffer << read_underlying([4096, limit - @buffer.bytesize].min, @timeout)
|
|
78
|
+
end
|
|
79
|
+
rescue EOFError
|
|
80
|
+
return nil if @buffer.empty?
|
|
81
|
+
|
|
82
|
+
raise ProtocolError, "truncated line"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# 同时限制单行、头部总字节数和头部数量;读取终止 chunk 后,
|
|
86
|
+
# 为分块传输的尾部字段建立独立预算。
|
|
87
|
+
def begin_headers!
|
|
88
|
+
@line_budget = [0, 0]
|
|
89
|
+
@track_trailers = true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def write(bytes, timeout: @timeout)
|
|
93
|
+
IOHelpers.write_all(@io, bytes, timeout: timeout)
|
|
94
|
+
bytes.bytesize
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def flush
|
|
98
|
+
@io.flush if @io.respond_to?(:flush)
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def close
|
|
103
|
+
IOHelpers.close(@io) unless @io.closed?
|
|
104
|
+
rescue IOError, SystemCallError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def closed? = @io.closed?
|
|
109
|
+
|
|
110
|
+
# 本地 TCP socket 可停止上传读取,同时继续返回 HTTP 错误。
|
|
111
|
+
# AnyTLS 逻辑流只支持整体关闭。
|
|
112
|
+
def close_read
|
|
113
|
+
if @io.is_a?(BasicSocket)
|
|
114
|
+
@io.shutdown(Socket::SHUT_RD)
|
|
115
|
+
else
|
|
116
|
+
close
|
|
117
|
+
end
|
|
118
|
+
rescue IOError, SystemCallError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def close_write
|
|
123
|
+
if @io.is_a?(IO) && @io.respond_to?(:close_write)
|
|
124
|
+
@io.close_write
|
|
125
|
+
else
|
|
126
|
+
raise ProtocolError, "AnyTLS streams do not support half-close"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def peeraddr = @io.peeraddr
|
|
131
|
+
|
|
132
|
+
def addr = @io.addr
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def fill(length)
|
|
137
|
+
@buffer << read_underlying([length - @buffer.bytesize, 4096].max, @timeout) while @buffer.bytesize < length
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def read_underlying(length, timeout)
|
|
141
|
+
if @deadline
|
|
142
|
+
remaining = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
143
|
+
raise TimeoutError, "read timed out" unless remaining.positive?
|
|
144
|
+
|
|
145
|
+
timeout = timeout ? [timeout, remaining].min : remaining
|
|
146
|
+
end
|
|
147
|
+
unless @io.respond_to?(:read_nonblock)
|
|
148
|
+
return @io.readpartial(length, timeout: timeout)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
|
|
152
|
+
loop do
|
|
153
|
+
result = @io.read_nonblock(length, exception: false)
|
|
154
|
+
case result
|
|
155
|
+
when :wait_readable, :wait_writable
|
|
156
|
+
remaining = deadline && (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
157
|
+
raise TimeoutError, "read timed out" if remaining && remaining <= 0
|
|
158
|
+
|
|
159
|
+
ready = IO.select(result == :wait_readable ? [@io] : nil, result == :wait_writable ? [@io] : nil, nil,
|
|
160
|
+
remaining)
|
|
161
|
+
raise TimeoutError, "read timed out" unless ready
|
|
162
|
+
when nil
|
|
163
|
+
raise EOFError
|
|
164
|
+
else
|
|
165
|
+
return result
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def account_line(line)
|
|
171
|
+
if @line_budget
|
|
172
|
+
@line_budget[0] += line.bytesize
|
|
173
|
+
@line_budget[1] += 1 unless line == "\r\n"
|
|
174
|
+
if @line_budget[0] > 65_536 || @line_budget[1] > 101
|
|
175
|
+
raise BufferLimitExceeded, "HTTP headers exceed limit"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
@line_budget = nil if line == "\r\n"
|
|
179
|
+
elsif @track_trailers && line.match?(/\A0+(?:;[^\r\n]*)?\r\n\z/)
|
|
180
|
+
@line_budget = [0, 1] # 尾部字段没有请求行或状态行。
|
|
181
|
+
end
|
|
182
|
+
line
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyTLS
|
|
4
|
+
# 有界二进制接收队列;生产者不阻塞,读取者可等待数据。
|
|
5
|
+
# 远端结束后先读完缓冲再报告 EOF 或异常;本地关闭则丢弃缓冲。
|
|
6
|
+
class ByteBuffer
|
|
7
|
+
def initialize(limit: 262_144)
|
|
8
|
+
@limit = limit
|
|
9
|
+
@bytes = String.new(encoding: Encoding::BINARY)
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@changed = ConditionVariable.new
|
|
12
|
+
@finished = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def size
|
|
16
|
+
@mutex.synchronize { @bytes.bytesize }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def inspect
|
|
20
|
+
"#<#{self.class} bytes=#{size} limit=#{@limit}>"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def push(bytes)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
return if @finished
|
|
26
|
+
raise BufferLimitExceeded, "stream receive buffer limit exceeded" if @bytes.bytesize + bytes.bytesize > @limit
|
|
27
|
+
|
|
28
|
+
@bytes << bytes
|
|
29
|
+
@changed.broadcast
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def readpartial(length, deadline:)
|
|
34
|
+
raise ArgumentError, "length must be positive" unless length.is_a?(Integer) && length.positive?
|
|
35
|
+
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
deadline.wait(@changed, @mutex) while @bytes.empty? && !@finished
|
|
38
|
+
if @bytes.empty?
|
|
39
|
+
raise @error if @error
|
|
40
|
+
|
|
41
|
+
raise EOFError, "end of stream"
|
|
42
|
+
end
|
|
43
|
+
count = [length, @bytes.bytesize].min
|
|
44
|
+
result = @bytes.byteslice(0, count)
|
|
45
|
+
@bytes = @bytes.byteslice(count..) || String.new(encoding: Encoding::BINARY)
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def finish(error = nil, discard: false)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
unless @finished
|
|
53
|
+
@error = error
|
|
54
|
+
@finished = true
|
|
55
|
+
end
|
|
56
|
+
@bytes.clear if discard
|
|
57
|
+
@changed.broadcast
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module AnyTLS
|
|
6
|
+
module CLI
|
|
7
|
+
# 有界本地 TCP 接入;回收工作线程,并允许资源耗尽后恢复接入。
|
|
8
|
+
# 持有监听器和混合代理适配器;停止时也关闭客户端会话池。
|
|
9
|
+
class ClientListener
|
|
10
|
+
def initialize(client:, options:, logger:)
|
|
11
|
+
@client = client
|
|
12
|
+
@options = options
|
|
13
|
+
@logger = logger
|
|
14
|
+
@mixed = Inbound::Mixed.new(client: client, logger: logger,
|
|
15
|
+
handshake_timeout: options.fetch(:handshake_timeout))
|
|
16
|
+
@listener = TCPServer.new(options.fetch(:listen_host), options.fetch(:listen_port))
|
|
17
|
+
@workers = {}
|
|
18
|
+
@completed = Queue.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(shutdown)
|
|
22
|
+
address = @listener.local_address
|
|
23
|
+
@logger.info("mixed proxy listening on #{address.ip_address}:#{address.ip_port}")
|
|
24
|
+
loop do
|
|
25
|
+
reap_workers
|
|
26
|
+
ready = IO.select([@listener, shutdown.reader], nil, nil, 0.1)&.first
|
|
27
|
+
next unless ready
|
|
28
|
+
break if ready.include?(shutdown.reader)
|
|
29
|
+
|
|
30
|
+
accept_client(shutdown)
|
|
31
|
+
end
|
|
32
|
+
ensure
|
|
33
|
+
close
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def close
|
|
37
|
+
@listener.close unless @listener.closed?
|
|
38
|
+
@mixed.close
|
|
39
|
+
@client.close
|
|
40
|
+
@workers.each_key { |socket| socket.close unless socket.closed? }
|
|
41
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @options.fetch(:grace)
|
|
42
|
+
@workers.each_value do |thread|
|
|
43
|
+
remaining = [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
|
|
44
|
+
raise IOError, "proxy worker did not stop" unless thread.join(remaining)
|
|
45
|
+
|
|
46
|
+
thread.value
|
|
47
|
+
end
|
|
48
|
+
@workers.clear
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def accept_client(shutdown)
|
|
54
|
+
socket = @listener.accept_nonblock(exception: false)
|
|
55
|
+
return if socket == :wait_readable
|
|
56
|
+
|
|
57
|
+
if @workers.size >= @options.fetch(:max_connections)
|
|
58
|
+
socket.close
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
start_worker(socket)
|
|
62
|
+
@resource_limited = false
|
|
63
|
+
rescue Errno::EMFILE, Errno::ENFILE, ThreadError => error
|
|
64
|
+
socket.close if socket.is_a?(IO) && !socket.closed?
|
|
65
|
+
@logger.warn("proxy admission paused (#{error.class})") unless @resource_limited
|
|
66
|
+
@resource_limited = true
|
|
67
|
+
IO.select([shutdown.reader], nil, nil, 0.05)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def start_worker(socket)
|
|
71
|
+
@workers[socket] = Thread.new do
|
|
72
|
+
@mixed.handle(socket)
|
|
73
|
+
rescue StandardError => error
|
|
74
|
+
@logger.warn("proxy connection failed (#{error.class})")
|
|
75
|
+
ensure
|
|
76
|
+
socket.close unless socket.closed?
|
|
77
|
+
@completed << socket
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def reap_workers
|
|
82
|
+
loop do
|
|
83
|
+
socket = @completed.pop(true)
|
|
84
|
+
@workers.delete(socket)&.join
|
|
85
|
+
rescue ThreadError
|
|
86
|
+
break
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
module AnyTLS
|
|
7
|
+
# 命令行编排层;核心对象不读取进程参数或 ENV。
|
|
8
|
+
module CLI
|
|
9
|
+
# 解析兼容及当前选项,按优先级读取显式参数、URI 和环境变量中的凭据。
|
|
10
|
+
# 监听前完成校验;帮助和版本查询无需凭据,也不启动网络 I/O。
|
|
11
|
+
class Options
|
|
12
|
+
LEGACY_FLAGS = { "-sni" => "--sni", "-dr" => "--disable-reuse",
|
|
13
|
+
"-padding-scheme" => "--padding-scheme" }.freeze
|
|
14
|
+
VALUE_FLAGS = %w[-l --listen -p --password --password-file --connect-timeout --handshake-timeout
|
|
15
|
+
--max-sessions --grace --padding-scheme --log-level -s --server --sni --ca-file
|
|
16
|
+
-m --min-idle-sessions --open-timeout --max-connections --cert --key --max-streams].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :values, :parser
|
|
19
|
+
|
|
20
|
+
def initialize(mode, env: ENV)
|
|
21
|
+
@mode = mode
|
|
22
|
+
@env = env
|
|
23
|
+
@values = { listen: mode == :client ? "127.0.0.1:1080" : "0.0.0.0:8443",
|
|
24
|
+
min_idle_sessions: 5, reuse: true, connect_timeout: 5.0,
|
|
25
|
+
handshake_timeout: 5.0, open_timeout: 5.0, max_sessions: 128,
|
|
26
|
+
max_connections: 256, max_streams: 256, grace: 5.0 }
|
|
27
|
+
@parser = OptionParser.new
|
|
28
|
+
configure
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse(argv)
|
|
32
|
+
args = normalize_legacy(argv)
|
|
33
|
+
parser.parse!(args)
|
|
34
|
+
raise ArgumentError, "unexpected positional arguments" unless args.empty?
|
|
35
|
+
return values if values[:help] || values[:version]
|
|
36
|
+
|
|
37
|
+
validate!
|
|
38
|
+
values
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def normalize_legacy(argv)
|
|
44
|
+
value_follows = false
|
|
45
|
+
argv.map do |argument|
|
|
46
|
+
if value_follows
|
|
47
|
+
value_follows = false
|
|
48
|
+
next argument
|
|
49
|
+
end
|
|
50
|
+
flag, value = argument.split("=", 2)
|
|
51
|
+
flag = LEGACY_FLAGS.fetch(flag, flag)
|
|
52
|
+
value_follows = value.nil? && VALUE_FLAGS.include?(flag)
|
|
53
|
+
value.nil? ? flag : "#{flag}=#{value}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def configure
|
|
58
|
+
parser.banner = "Usage: anytls-#{@mode} [options]"
|
|
59
|
+
parser.on("-l", "--listen ADDRESS", "Listen address (bracket IPv6)") { |v| values[:listen] = v }
|
|
60
|
+
parser.on("-p", "--password PASSWORD", "Password; prefer a file or ANYTLS_PASSWORD") do |v|
|
|
61
|
+
values[:password_argument] = v
|
|
62
|
+
end
|
|
63
|
+
parser.on("--password-file PATH", "Read password from a private file") { |v| values[:password_file] = v }
|
|
64
|
+
parser.on("--connect-timeout SECONDS", Float) { |v| values[:connect_timeout] = v }
|
|
65
|
+
parser.on("--handshake-timeout SECONDS", Float) { |v| values[:handshake_timeout] = v }
|
|
66
|
+
parser.on("--max-sessions COUNT", Integer) { |v| values[:max_sessions] = v }
|
|
67
|
+
parser.on("--grace SECONDS", Float, "Shutdown grace period") { |v| values[:grace] = v }
|
|
68
|
+
parser.on("--padding-scheme PATH") { |v| values[:padding_file] = v }
|
|
69
|
+
parser.on("--log-level LEVEL", %w[debug info warn error fatal]) { |v| values[:log_level] = v }
|
|
70
|
+
@mode == :client ? configure_client : configure_server
|
|
71
|
+
parser.on("-h", "--help", "Show this help") { values[:help] = true }
|
|
72
|
+
parser.on("-v", "--version", "Show version") { values[:version] = true }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def configure_client
|
|
76
|
+
parser.on("-s", "--server ADDRESS", "Server host:port or anytls:// URI") { |v| values[:server] = v }
|
|
77
|
+
parser.on("--sni HOST") { |v| values[:sni] = v }
|
|
78
|
+
parser.on("--ca-file PATH") { |v| values[:ca_file] = v }
|
|
79
|
+
parser.on("--insecure", "Explicitly disable TLS certificate verification") { values[:insecure] = true }
|
|
80
|
+
parser.on("-m", "--min-idle-sessions COUNT", Integer) { |v| values[:min_idle_sessions] = v }
|
|
81
|
+
parser.on("--disable-reuse", "Use a fresh session for every connection") { values[:reuse] = false }
|
|
82
|
+
parser.on("--open-timeout SECONDS", Float) { |v| values[:open_timeout] = v }
|
|
83
|
+
parser.on("--max-connections COUNT", Integer) { |v| values[:max_connections] = v }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def configure_server
|
|
87
|
+
parser.on("--cert PATH", "PEM certificate chain") { |v| values[:cert] = v }
|
|
88
|
+
parser.on("--key PATH", "PEM private key") { |v| values[:key] = v }
|
|
89
|
+
parser.on("--self-signed", "Generate an ephemeral development certificate") { values[:self_signed] = true }
|
|
90
|
+
parser.on("--max-streams COUNT", Integer) { |v| values[:max_streams] = v }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate!
|
|
94
|
+
values[:listen_host], values[:listen_port] = parse_endpoint(values[:listen], allow_zero: true)
|
|
95
|
+
parse_server if @mode == :client
|
|
96
|
+
values[:password] = resolve_password
|
|
97
|
+
raise ArgumentError, "password is required" if values[:password].nil? || values[:password].empty?
|
|
98
|
+
|
|
99
|
+
%i[connect_timeout handshake_timeout open_timeout grace].each do |key|
|
|
100
|
+
value = values.fetch(key)
|
|
101
|
+
raise ArgumentError, "#{key} must be positive and finite" unless value.finite? && value.positive?
|
|
102
|
+
end
|
|
103
|
+
%i[max_sessions max_connections max_streams].each do |key|
|
|
104
|
+
raise ArgumentError, "#{key} must be positive" unless values.fetch(key).positive?
|
|
105
|
+
end
|
|
106
|
+
if @mode == :client && values[:min_idle_sessions].negative?
|
|
107
|
+
raise ArgumentError, "min_idle_sessions must be nonnegative"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
validate_certificate! if @mode == :server
|
|
111
|
+
values[:log_level] ||= %w[debug info warn error fatal].include?(@env["LOG_LEVEL"]) ? @env["LOG_LEVEL"] : "info"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_server
|
|
115
|
+
server = values[:server]
|
|
116
|
+
raise ArgumentError, "server is required" unless server
|
|
117
|
+
|
|
118
|
+
if server.start_with?("anytls://")
|
|
119
|
+
uri = ConnectionURI.parse(server)
|
|
120
|
+
values[:host], values[:port] = uri.values_at(:host, :port)
|
|
121
|
+
values[:uri_password] = uri[:password]
|
|
122
|
+
values[:sni] ||= uri[:sni]
|
|
123
|
+
values[:insecure] = uri[:insecure] unless values.key?(:insecure)
|
|
124
|
+
else
|
|
125
|
+
values[:host], values[:port] = parse_endpoint(server)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_endpoint(value, allow_zero: false)
|
|
130
|
+
match = /\A(?:\[([^\]]+)\]|([^:\s]+)):(\d+)\z/.match(value)
|
|
131
|
+
raise ArgumentError, "expected host:port or [IPv6]:port" unless match
|
|
132
|
+
|
|
133
|
+
port = Integer(match[3], 10)
|
|
134
|
+
raise ArgumentError, "port is out of range" unless port.between?(allow_zero ? 0 : 1, 65_535)
|
|
135
|
+
|
|
136
|
+
[match[1] || match[2], port]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def resolve_password
|
|
140
|
+
if values[:password_argument] && values[:password_file]
|
|
141
|
+
raise ArgumentError, "choose password or password-file"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
if values[:password_file]
|
|
145
|
+
File.binread(values[:password_file]).sub(/\r?\n\z/, "")
|
|
146
|
+
else
|
|
147
|
+
values[:password_argument] || values[:uri_password] || @env["ANYTLS_PASSWORD"]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_certificate!
|
|
152
|
+
if values[:self_signed]
|
|
153
|
+
raise ArgumentError, "self-signed cannot be combined with cert/key" if values[:cert] || values[:key]
|
|
154
|
+
elsif !values[:cert] || !values[:key]
|
|
155
|
+
raise ArgumentError, "server requires cert and key, or explicit self-signed"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "options"
|
|
4
|
+
require_relative "shutdown"
|
|
5
|
+
require_relative "client_listener"
|
|
6
|
+
|
|
7
|
+
module AnyTLS
|
|
8
|
+
module CLI
|
|
9
|
+
# 进程入口:创建资源、等待停止,并返回脱敏诊断与退出码。
|
|
10
|
+
# 退出码为 0(成功)、2(参数错误)、1(运行失败)。
|
|
11
|
+
class Runner
|
|
12
|
+
def self.run(mode, argv = ARGV, env: ENV, stdout: $stdout, stderr: $stderr)
|
|
13
|
+
new(mode, argv, env: env, stdout: stdout, stderr: stderr).run
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(mode, argv, env:, stdout:, stderr:)
|
|
17
|
+
@mode, @argv, @env, @stdout, @stderr = mode, argv, env, stdout, stderr
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
options = Options.new(@mode, env: @env)
|
|
22
|
+
values = options.parse(@argv)
|
|
23
|
+
if values[:help]
|
|
24
|
+
@stdout.puts(options.parser)
|
|
25
|
+
return 0
|
|
26
|
+
end
|
|
27
|
+
if values[:version]
|
|
28
|
+
@stdout.puts(AnyTLS::VERSION)
|
|
29
|
+
return 0
|
|
30
|
+
end
|
|
31
|
+
logger = Logger.new(@stderr, level: values.fetch(:log_level))
|
|
32
|
+
logger.formatter = ->(severity, _time, _name, message) { "#{severity}: #{message}\n" }
|
|
33
|
+
shutdown = Shutdown.new.install
|
|
34
|
+
@mode == :client ? run_client(values, logger, shutdown) : run_server(values, logger, shutdown)
|
|
35
|
+
0
|
|
36
|
+
rescue OptionParser::ParseError, ArgumentError
|
|
37
|
+
@stderr.puts("error: invalid options; use --help")
|
|
38
|
+
2
|
|
39
|
+
rescue StandardError => error
|
|
40
|
+
@stderr.puts("error: #{@mode} failed (#{error.class})")
|
|
41
|
+
1
|
|
42
|
+
ensure
|
|
43
|
+
shutdown&.close
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def padding(values)
|
|
49
|
+
return unless values[:padding_file]
|
|
50
|
+
|
|
51
|
+
Padding::Scheme.new(File.binread(values[:padding_file]))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def run_client(values, logger, shutdown)
|
|
55
|
+
tls = TLSConfig.new(ca_file: values[:ca_file], sni: values[:sni], insecure: values.fetch(:insecure, false))
|
|
56
|
+
client = Client.new(host: values.fetch(:host), port: values.fetch(:port), password: values.fetch(:password),
|
|
57
|
+
tls: tls, min_idle_sessions: values.fetch(:min_idle_sessions), reuse: values.fetch(:reuse),
|
|
58
|
+
connect_timeout: values.fetch(:connect_timeout),
|
|
59
|
+
handshake_timeout: values.fetch(:handshake_timeout),
|
|
60
|
+
open_timeout: values.fetch(:open_timeout),
|
|
61
|
+
max_sessions: values.fetch(:max_sessions), logger: logger, padding: padding(values))
|
|
62
|
+
listener = ClientListener.new(client: client, options: values, logger: logger)
|
|
63
|
+
listener.run(shutdown)
|
|
64
|
+
ensure
|
|
65
|
+
client&.close
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def run_server(values, logger, shutdown)
|
|
69
|
+
tls = TLSConfig.new(server: true, cert: values[:cert], key: values[:key],
|
|
70
|
+
self_signed: values.fetch(:self_signed, false))
|
|
71
|
+
server = Server.new(host: values.fetch(:listen_host), port: values.fetch(:listen_port),
|
|
72
|
+
password: values.fetch(:password), tls: tls, padding: padding(values),
|
|
73
|
+
connect_timeout: values.fetch(:connect_timeout),
|
|
74
|
+
handshake_timeout: values.fetch(:handshake_timeout),
|
|
75
|
+
max_sessions: values.fetch(:max_sessions), max_streams: values.fetch(:max_streams),
|
|
76
|
+
logger: logger)
|
|
77
|
+
worker = Thread.new { server.run }
|
|
78
|
+
loop do
|
|
79
|
+
break unless worker.alive?
|
|
80
|
+
break if IO.select([shutdown.reader], nil, nil, 0.1)
|
|
81
|
+
end
|
|
82
|
+
server.stop(grace: values.fetch(:grace))
|
|
83
|
+
worker.value
|
|
84
|
+
ensure
|
|
85
|
+
server&.stop(grace: values.fetch(:grace))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module AnyTLS
|
|
6
|
+
module CLI
|
|
7
|
+
# 信号处理器仅写入唤醒 socket;由正常线程执行停止流程。
|
|
8
|
+
# 安装者持有 socket 两端,关闭时恢复原信号处理器。
|
|
9
|
+
class Shutdown
|
|
10
|
+
attr_reader :reader
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@reader, @writer = Socket.pair(:UNIX, :STREAM, 0)
|
|
14
|
+
@previous = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def install
|
|
18
|
+
%w[INT TERM].each do |signal|
|
|
19
|
+
@previous[signal] = Signal.trap(signal) do
|
|
20
|
+
@writer.write_nonblock("x", exception: false)
|
|
21
|
+
rescue IOError, SystemCallError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def close
|
|
29
|
+
@previous.each { |signal, handler| Signal.trap(signal, handler) }
|
|
30
|
+
@previous.clear
|
|
31
|
+
@reader.close unless @reader.closed?
|
|
32
|
+
@writer.close unless @writer.closed?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|