LiquidProxy Lua Edition
1-- Inspired, and basically copied from Wowfunhappy's AquaProxy.
2-- The comments are literally 1-to-1.
3
4--[[
5Copyright (c) 2024 Wowfunhappy
6 2015 Keith Rarick
7
8Permission is hereby granted, free of charge, to any person obtaining a copy of
9this software and associated documentation files (the "Software"), to deal in
10the Software without restriction, including without limitation the rights to
11use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
12of the Software, and to permit persons to whom the Software is furnished to do
13so, subject to the following conditions:
14
15The above copyright notice and this permission notice shall be included in all
16copies or substantial portions of the Software.
17
18THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24SOFTWARE.
25]]
26
27local bit = require "bit"
28local wr = require "coro-channel".wrapRead
29
30-- https://stackoverflow.com/a/65477617 by DarkWiiPlayer + Luatic
31local function hex(str)
32 return (str:gsub("%x%x", function(digits) return string.char(tonumber(digits, 16)) end))
33end
34
35local function str(hex)
36 return (hex:gsub(".", function(char) return string.format("%02x", char:byte()) end))
37end
38
39-- Go int
40local function int(hex)
41 return tonumber(str(hex), 16)
42end
43
44local function at(str, index)
45 return str:sub(index, index)
46end
47
48local const = {
49 tlsHandshakeTypeClientHello = 0x01,
50
51 tlsExtensionServerName = 0x0000,
52 tlsExtensionALPN = 0x0010,
53 tlsExtensionSupportedVersions = 0x002b,
54
55 tlsVersion10 = 0x0301,
56 tlsVersion11 = 0x0302,
57 tlsVersion12 = 0x0303,
58 tlsVersion13 = 0x0304
59}
60
61local function parseALPN(buf, info)
62 if buf:len() < 2 then
63 return
64 end
65
66 local protocolListLen = bit.bor(bit.lshift(int(at(buf, 1)), 8), int(at(buf, 2)))
67 local pos = 3
68
69 while pos < protocolListLen + 2 and pos < buf:len() do
70 local protoLen = int(at(buf, pos))
71 pos = pos + 1
72 if pos + protoLen <= buf:len() then
73 local proto = (buf:sub(pos, pos + protoLen - 1))
74 table.insert(info.alpnProtocols, proto)
75
76 if proto == "h2" then
77 info.supportsHTTP2 = true
78 end
79 end
80
81 pos = pos + protoLen
82 end
83end
84
85local function parseSupportedVersions(buf, info)
86 if buf:len() < 1 then
87 return
88 end
89
90 -- For ClientHello, this is a list
91 local listLen = int(at(buf, 1))
92 local pos = 2
93
94 local i = 1
95 while i < listLen / 2 and pos + 2 <= buf:len() do
96 local version = bit.bor(bit.lshift(int(at(buf , pos)), 8), int(at(buf, pos + 1)))
97 if version == const.tlsVersion13 then
98 info.supportsTLS13 = true
99 end
100 info.tlsVersions[version] = true
101 pos = pos + 2
102 i = i + 1
103 end
104end
105
106local function parseServerName(buf, info)
107 if buf:len() < 1 then
108 return
109 end
110
111 local listLen = int(buf:sub(1, 2))
112 local snType = int(at(buf, 3))
113 if snType ~= 0 then return end
114 local pos = 4
115
116 while pos < listLen + 3 and pos < buf:len() do
117 local snLen = int(buf:sub(pos, pos + 1))
118 pos = pos + 2
119 if pos + snLen <= buf:len() then
120 local sn = (buf:sub(pos, pos + snLen - 1))
121 table.insert(info.serverNames, sn)
122 end
123
124 pos = pos + snLen
125 end
126end
127
128---@param info info
129---@return string?
130local function parseExtensions(buf, info)
131 local pos = 1
132
133 while pos + 4 <= buf:len() do
134 local extType = bit.bor(bit.lshift(int(at(buf , pos)), 8), int(at(buf, pos + 1)))
135 local extLen = bit.bor(bit.lshift(int(at(buf, pos + 2)), 8), int(at(buf, pos + 3)))
136 pos = pos + 4
137
138 if pos + extLen - 1 > buf:len() then
139 return "truncated extension"
140 end
141
142 local extData = buf:sub(pos, pos + extLen)
143
144 if extType == const.tlsExtensionALPN then
145 parseALPN(extData, info)
146 elseif extType == const.tlsExtensionSupportedVersions then
147 parseSupportedVersions(extData, info)
148 elseif extType == const.tlsExtensionServerName then
149 parseServerName(extData, info)
150 end
151
152 pos = pos + extLen
153 end
154
155 return nil
156end
157
158---@return string buffer
159---@return info?
160---@return string? err
161---@return boolean notHS
162local function tlspeek(socket)
163 ---@class info
164 local info = {
165 supportsTLS13 = false,
166 supportsHTTP2 = false,
167 ---@type table<integer, boolean>
168 tlsVersions = {},
169 serverNames = {},
170 alpnProtocols = {}
171 }
172 socket:read_stop()
173 local buf = wr(socket)()
174 if not buf then return "", nil, "no data received", false end
175 local len = buf:len()
176
177 local _, err, nhs = pcall(function()
178 -- Minimum size check: 5 bytes for TLS record header + 4 bytes for handshake header
179 if len < 9 then
180 return "data too short to be ClientHello", true
181 end
182
183 -- Check TLS record header
184 if at(buf, 1) ~= hex("16") then -- Handshake record type
185 return "not a TLS handshake record", true
186 end
187
188 -- Skip TLS version from record header (backwards compatibility version)
189
190 -- Get record length
191 local recordLen = bit.bor(bit.lshift(int(at(buf, 4)), 8), int(at(buf, 5)))
192 if len < recordLen + 5 then
193 return "incomplete TLS record", true
194 end
195
196 -- Parse handshake message
197
198 local pos = 6
199 if int(at(buf, pos)) ~= const.tlsHandshakeTypeClientHello then
200 return "not a ClientHello message", true
201 end
202
203 -- Skip handshake length (3 bytes...?)
204 pos = pos + 4
205
206 if len < pos + 2 then
207 return "truncated ClientHello"
208 end
209
210 info.tlsVersion = bit.bor(bit.lshift(int(at(buf , pos)), 8), int(at(buf, pos + 1)))
211 pos = pos + 2
212
213 -- Skip client random (32 bytes)
214 pos = pos + 32
215
216 -- Skip session ID
217 if len < pos + 1 then
218 return "truncated ClientHello at session ID"
219 end
220 local sessionIDLen = int(at(buf, pos))
221 pos = pos + sessionIDLen + 1
222
223 -- Skip cipher suites
224 if len < pos + 2 then
225 return "truncated ClientHello at cipher suites"
226 end
227 local cipherSuitesLen = bit.bor(bit.lshift(int(at(buf, pos)), 8), int(at(buf, pos + 1)))
228 pos = pos + cipherSuitesLen + 2
229
230 -- Skip compression methods
231 if len < pos + 1 then
232 return "truncated ClientHello at compression"
233 end
234 local compressionLen = int(at(buf, pos))
235 pos = pos + compressionLen + 1
236
237 if len > pos + 2 then
238 local extensionsLen = bit.bor(bit.lshift(int(at(buf, pos)), 8), int(at(buf, pos + 1)))
239 pos = pos + 2
240
241 if len >= pos + extensionsLen - 1 then
242 local err = parseExtensions(buf:sub(pos, pos + extensionsLen), info)
243 if err then
244 return err
245 end
246 end
247 end
248
249 info.isModernClient = info.supportsTLS13 or info.supportsHTTP2
250 end)
251 if err then return buf, nil, err, nhs or false end
252
253 return buf, info, nil, false
254end
255
256return {
257 peek = tlspeek,
258 const = const
259}