├── README.md ├── dist.ini ├── lib └── resty │ └── sniproxy.lua └── lua-resty-sniproxy-0.22-1.rockspec /README.md: -------------------------------------------------------------------------------- 1 | Name 2 | ==== 3 | 4 | lua-resty-sniproxy - SNI Proxy based on the ngx_lua cosocket API 5 | 6 | Table of Contents 7 | ================= 8 | 9 | - [Description](#description) 10 | - [Status](#status) 11 | - [Synopsis](#synopsis) 12 | - [TODO](#todo) 13 | - [Copyright and License](#copyright-and-license) 14 | - [See Also](#see-also) 15 | 16 | 17 | Description 18 | =========== 19 | 20 | This library is an [SNI](https://en.wikipedia.org/wiki/Server_Name_Indication) proxy written in Lua. TLS parsing part is rewritten from [dlundquist/sniproxy](https://github.com/dlundquist/sniproxy) 21 | 22 | Note that nginx [stream module](https://nginx.org/en/docs/stream/ngx_stream_core_module.html) and [ngx_stream_lua_module](https://github.com/openresty/stream-lua-nginx-module) is required. 23 | 24 | Tested on Openresty >= 1.9.15.1. 25 | 26 | [Back to TOC](#table-of-contents) 27 | 28 | Status 29 | ======== 30 | 31 | Experimental. 32 | 33 | Synopsis 34 | ======== 35 | 36 | 37 | ``` 38 | stream { 39 | init_by_lua_block { 40 | local sni = require("resty.sniproxy") 41 | sni.rules = { 42 | {"www.google.com", "www.google.com", 443}, 43 | {"www.facebook.com", "9.8.7.6", 443}, 44 | {"api.twitter.com", "1.2.3.4"}, 45 | {".+.twitter.com", nil, 443}, 46 | -- to activate this rule, you must use Lua land proxying 47 | -- {"some.service.svc", "unix:/var/run/nginx-proxy-proto.sock", nil, sni.SNI_PROXY_PROTOCOL_UPSTREAM}, 48 | -- {"some2.service.svc", "unix:/var/run/nginx-proxy-proto.sock", nil, 49 | -- sni.SNI_PROXY_PROTOCOL_UPSTREAM + sni.SNI_PROXY_PROTOCOL}, 50 | {".", "unix:/var/run/nginx-default.sock"} 51 | } 52 | } 53 | 54 | # for OpenResty >= 1.13.6.1, native Nginx proxying 55 | lua_add_variable $sniproxy_upstream; 56 | server { 57 | error_log /var/log/nginx/sniproxy-error.log error; 58 | listen 443; 59 | 60 | resolver 8.8.8.8; 61 | 62 | preread_by_lua_block { 63 | local sni = require("resty.sniproxy") 64 | local sp = sni:new() 65 | sp:preread_by() 66 | } 67 | proxy_pass $sniproxy_upstream; 68 | } 69 | 70 | # for OpenResty < 1.13.6.1 or `flags` are configured, Lua land proxying 71 | server { 72 | error_log /var/log/nginx/sniproxy-error.log error; 73 | listen 443; 74 | 75 | resolver 8.8.8.8; 76 | 77 | content_by_lua_block { 78 | local sni = require("resty.sniproxy") 79 | local sp = sni:new() 80 | sp:content_by() 81 | } 82 | } 83 | } 84 | ``` 85 | 86 | A Lua array table `sni_rules` should be defined in the `init_worker_by_lua_block` directive. 87 | 88 | The first value can be either whole host name or regular expression. Use `.` for a default host name. If no entry is matched, connection will be closed. 89 | 90 | The second and third values are target host name and port. A host can be DNS name, IP address or UNIX domain socket path. If host is not defined or set to `nil`, **server_name** in SNI will be used. If the port is not defined or set to `nil` , **443** will be used. 91 | 92 | The forth value is the flags to use. Available flags are: 93 | 94 | 95 | sni.SNI_PROXY_PROTOCOL -- use client address received from proxy protocol to send to upstream 96 | sni.SNI_PROXY_PROTOCOL_UPSTREAM -- send proxy protocol v1 handshake to upstream 97 | 98 | 99 | To use flags, the server must be configured to do **Lua land proxying** (see above example). 100 | 101 | 102 | Rules are applied with the priority as its occurrence sequence in the table. In the example above, **api.twitter.com** will match the third rule **api.twitter.com** rather than the fourth **.+.twitter.com**. 103 | 104 | If the protocol version is less than TLSv1 (eg. SSLv3, SSLv2), connection will be closed, since SNI extension is not supported in these versions. 105 | 106 | [Back to TOC](#table-of-contents) 107 | 108 | 109 | TODO 110 | ==== 111 | 112 | - stress and performance test 113 | 114 | [Back to TOC](#table-of-contents) 115 | 116 | 117 | Copyright and License 118 | ===================== 119 | 120 | This module is licensed under the BSD license. 121 | 122 | Copyright (C) 2016, by fffonion . 123 | 124 | All rights reserved. 125 | 126 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 127 | 128 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 129 | 130 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 131 | 132 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 133 | 134 | [Back to TOC](#table-of-contents) 135 | 136 | See Also 137 | ======== 138 | * the ngx_stream_lua_module: https://github.com/openresty/stream-lua-nginx-module 139 | * [dlundquist/sniproxy] (https://github.com/dlundquist/sniproxy) 140 | * [ngx_stream_ssl_preread_module] (https://nginx.org/en/docs/stream/ngx_stream_ssl_preread_module.html) is available since Nginx 1.11.5 as an alternative to this module. 141 | 142 | [Back to TOC](#table-of-contents) 143 | -------------------------------------------------------------------------------- /dist.ini: -------------------------------------------------------------------------------- 1 | name = lua-resty-sniproxy 2 | abstract = SNI Proxy based on ngx_stream_lua_module 3 | author = fffonion 4 | is_original = yes 5 | license = 3bsd 6 | lib_dir = lib 7 | doc_dir = lib 8 | repo_link = https://github.com/fffonion/lua-resty-sniproxy 9 | main_module = lib/resty/sniproxy.lua 10 | requires = luajit 11 | -------------------------------------------------------------------------------- /lib/resty/sniproxy.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | local sub = string.sub 4 | local byte = string.byte 5 | local format = string.format 6 | local tcp = ngx.socket.tcp 7 | local setmetatable = setmetatable 8 | local spawn = ngx.thread.spawn 9 | local wait = ngx.thread.wait 10 | 11 | local bit = require("bit") 12 | local lshift = bit.lshift 13 | 14 | local balancer = require "ngx.balancer" 15 | 16 | local TLS_HEADER_LEN = 5 17 | local TLS_HANDSHAKE_CONTENT_TYPE = 0x16 18 | local TLS_HANDSHAKE_TYPE_CLIENT_HELLO = 0x01 19 | local TLS_HEADER_MAX_LENGHTH = 2048 -- anti dos attack 20 | 21 | 22 | local ok, new_tab = pcall(require, "table.new") 23 | if not ok or type(new_tab) ~= "function" then 24 | new_tab = function (narr, nrec) return {} end 25 | end 26 | 27 | 28 | local _M = new_tab(0, 8) 29 | _M._VERSION = '0.22' 30 | 31 | -- flags 32 | _M.SNI_PROXY_PROTOCOL = 0x1 33 | _M.SNI_PROXY_PROTOCOL_UPSTREAM = 0x2 34 | 35 | _M.rules = nil 36 | 37 | local mt = { __index = _M } 38 | 39 | local function check_version() 40 | if not ngx.config 41 | or not ngx.config.ngx_lua_version 42 | or ngx.config.ngx_lua_version < 6 43 | then 44 | return false 45 | end 46 | return true 47 | end 48 | 49 | local peak_supported = check_version() 50 | 51 | function _M.new(self, connect_timeout, send_timeout, read_timeout) 52 | if not _M.rules then 53 | return nil, "sni rules not defined" 54 | end 55 | 56 | local reqsock, err = ngx.req.socket() 57 | if not reqsock then 58 | return nil, err 59 | end 60 | reqsock:settimeouts(connect_timeout or 10000, send_timeout or 10000, read_timeout or 10000) 61 | 62 | return setmetatable({ 63 | reqsock = reqsock, 64 | server_name = nil, 65 | }, mt) 66 | end 67 | 68 | 69 | local function _parse_tls_header(sock, is_preread) 70 | local f = is_preread and sock.peek or sock.receive 71 | local server_name 72 | 73 | -- https://github.com/dlundquist/sniproxy/blob/master/src/tls.c 74 | local dt_overhead, err = f(sock, TLS_HEADER_LEN) 75 | if err then 76 | return nil, nil, "error reading from reqsock: " .. err 77 | end 78 | 79 | local dt_read 80 | 81 | -- parse TLS header starts 82 | 83 | -- hex_dump(dt_overhead) 84 | 85 | if (bit.band(byte(dt_overhead, 1), 0x80) > 0 and byte(dt_overhead, 3) == 1) then 86 | return nil, nil, "Received SSL 2.0 Client Hello which can not support SNI." 87 | end 88 | 89 | local tls_content_type = byte(dt_overhead, 1) 90 | if (tls_content_type ~= TLS_HANDSHAKE_CONTENT_TYPE) then 91 | return nil, nil, "Request did not begin with TLS handshake." 92 | end 93 | 94 | local tls_version_major = byte(dt_overhead, 2) 95 | local tls_version_minor = byte(dt_overhead, 3) 96 | if (tls_version_major < 3) then 97 | return nil, nil, format("Received SSL %d.%d handshake which which can not support SNI.", tls_version_major, tls_version_minor) 98 | end 99 | 100 | -- protocol: TLS record length 101 | local data_len = lshift(byte(dt_overhead, 4), 8) + byte(dt_overhead, 5) 102 | 103 | if data_len > TLS_HEADER_MAX_LENGHTH then 104 | return nil, nil, format("TLS ClientHello exceeds max length configured %d > %d", data_len, TLS_HEADER_MAX_LENGHTH) 105 | end 106 | 107 | -- protocol: Handshake 108 | local dt_record, err 109 | 110 | if is_preread then 111 | -- peek always start from beginning 112 | dt_read, err = f(sock, data_len + TLS_HEADER_LEN) 113 | if dt_read then 114 | dt_record = dt_read:sub(TLS_HEADER_LEN+1) 115 | end 116 | else 117 | dt_record, err = f(sock, data_len) 118 | if dt_record then 119 | dt_read = dt_overhead .. dt_record 120 | end 121 | end 122 | if err then 123 | return nil, nil, "error reading from reqsock: " .. err 124 | end 125 | 126 | -- hex_dump(dt_record) 127 | 128 | local pos = 1 129 | 130 | if (byte(dt_record, 1) ~= TLS_HANDSHAKE_TYPE_CLIENT_HELLO) then 131 | return nil, nil, "Not a client hello" 132 | end 133 | 134 | --[[ Skip past fixed length records: 135 | 1 Handshake Type 136 | 3 Length 137 | 2 Version (again) 138 | 32 Random 139 | to Session ID Length 140 | ]]-- 141 | pos = pos + 38; 142 | 143 | local len 144 | -- protocol: Session ID 145 | if (pos > data_len) then 146 | return nil, nil, "Protocol error: Session ID" 147 | end 148 | len = byte(dt_record, pos); 149 | pos = pos + 1 + len; 150 | 151 | -- protocol: Cipher Suites 152 | if (pos > data_len) then 153 | return nil, nil, "Protocol error: Cipher Suites" 154 | end 155 | len = lshift(byte(dt_record, pos), 8) + byte(dt_record, pos + 1) 156 | pos = pos + 2 + len; 157 | 158 | -- protocol: Compression Methods 159 | if (pos > data_len) then 160 | return nil, nil, "Protocol error: Compression Methods" 161 | end 162 | len = byte(dt_record, pos) 163 | pos = pos + 1 + len; 164 | 165 | if (pos == data_len and tls_version_major == 3 and tls_version_minor == 0) then 166 | return nil, nil, "Received SSL 3.0 handshake without extensions" 167 | end 168 | 169 | -- protocol: Extensions 170 | if (pos + 1 > data_len) then 171 | return nil, nil, "Protocol error: Extensions" 172 | end 173 | len = lshift(byte(dt_record, pos), 8) + byte(dt_record, pos + 1) 174 | pos = pos + 2; 175 | 176 | 177 | if (pos + len - 1 > data_len) then 178 | return nil, nil, "Protocol error: Extensions headers" 179 | end 180 | 181 | 182 | -- Parse each 4 bytes for the extension header 183 | 184 | while (pos + 3 <= data_len) do 185 | -- Extension Length */ 186 | len = lshift(byte(dt_record, pos + 2), 8) + byte(dt_record, pos + 3) 187 | 188 | -- Check if it's a server name extension */ 189 | if (byte(dt_record, pos) == 0 and byte(dt_record, pos + 1) == 0) then 190 | -- There can be only one extension of each type, 191 | -- so we break our state and move p to beinnging of the extension here 192 | if (pos + 3 + len > data_len) then 193 | return nil, nil, "Protocol error: Extension type" 194 | end 195 | pos = pos + 6 -- skip extension header(4) + server name list length(2) 196 | 197 | -- starts parse server name extension 198 | while (pos + 3 < data_len) do 199 | ngx.log(ngx.INFO, "pos is now", string.format("0x%0X", pos-1)) 200 | len = lshift(byte(dt_record, pos + 1), 8) + byte(dt_record, pos + 2) 201 | 202 | if (pos + 2 + len > data_len) then 203 | return nil, nil, "Protocol error: Extension data" 204 | end 205 | 206 | if(byte(dt_record, pos) ~= 0) then -- name type 207 | ngx.log(ngx.INFO, "Unknown server name extension name type:", string.format("0x%0X", byte(dt_record, pos))) 208 | else 209 | server_name = sub(dt_record, pos + 3, pos + 2 + len) 210 | return dt_read, server_name, nil 211 | end 212 | pos = pos + 3 + len; 213 | end 214 | 215 | --[[ Check we ended where we expected to */ 216 | if (pos ~= data_len) 217 | return -5; 218 | ]]-- 219 | 220 | return nil, nil, "Protocol error: extra data in Extensions" 221 | -- ends parse server name extension 222 | end 223 | pos = pos + 4 + len -- Advance to the next extension header 224 | end 225 | 226 | --[[Check we ended where we expected to 227 | if (pos ~= data_len) 228 | return -5; 229 | ]]-- 230 | 231 | return dt_read, server_name, nil 232 | end 233 | 234 | local function _select_upstream(server_name) 235 | local upstream, port, flags 236 | if not server_name then -- no sni extension, only match default rule 237 | server_name = "." 238 | end 239 | for _, v in pairs(_M.rules) do 240 | local m, e = ngx.re.match(server_name, v[1], "jo") 241 | if m then 242 | upstream = v[2] or server_name 243 | port = v[3] or 443 244 | flags = v[4] or 0 245 | break 246 | end 247 | end 248 | return upstream, port, flags, nil 249 | end 250 | 251 | function _M.preread_by(self) 252 | if not peak_supported then 253 | ngx.log(ngx.ERR, "reqsock:peak is required to use preread mode") 254 | ngx.exit(ngx.ERROR) 255 | end 256 | local _, server_name, err = _parse_tls_header(self.reqsock, true) 257 | if err then 258 | ngx.log(ngx.INFO, "tls header parsing error: ", err) 259 | ngx.exit(ngx.ERROR) 260 | end 261 | ngx.log(ngx.INFO, "tls server_name: ", server_name) 262 | 263 | local upstream, port, _ = _select_upstream(server_name) 264 | if upstream:sub(1, 5) ~= "unix:" then 265 | upstream = upstream .. ":" .. tostring(port) 266 | end 267 | ngx.log(ngx.INFO, "selecting upstream: ", upstream) 268 | ngx.var.sniproxy_upstream = upstream 269 | end 270 | 271 | 272 | local function _upl(self) 273 | -- proxy client request to server 274 | local buf, len, err, hd, discard 275 | local rsock = self.reqsock 276 | local ssock = self.srvsock 277 | while true do 278 | hd = rsock:receive(5) 279 | if not hd then 280 | break 281 | end 282 | len = lshift(byte(hd, 4), 8) + byte(hd, 5) 283 | buf = rsock:receive(len) 284 | if not buf then 285 | break 286 | end 287 | 288 | ssock:send(hd) 289 | discard, err = ssock:send(buf) 290 | if err then 291 | break 292 | end 293 | end 294 | end 295 | 296 | local function _dwn(self) 297 | -- proxy response to client 298 | local buf, len, err, hd, discard 299 | local rsock = self.reqsock 300 | local ssock = self.srvsock 301 | while true do 302 | hd = ssock:receive(5) 303 | if not hd then 304 | break 305 | end 306 | len = lshift(byte(hd, 4), 8) + byte(hd, 5) 307 | buf = ssock:receive(len) 308 | if not buf then 309 | break 310 | end 311 | 312 | rsock:send(hd) 313 | discard, err = rsock:send(buf) 314 | if err then 315 | break 316 | end 317 | end 318 | end 319 | 320 | local function inet_pton(addr) 321 | local family, binary 322 | return family, binary 323 | end 324 | 325 | function _M.content_by(self) 326 | local srvsock, err = tcp() 327 | if not srvsock then 328 | return nil, err 329 | end 330 | srvsock:settimeouts(connect_timeout or 10000, send_timeout or 10000, read_timeout or 10000) 331 | self.srvsock = srvsock 332 | 333 | while true do 334 | local header, server_name, err = _parse_tls_header(self.reqsock, false) 335 | if err then 336 | ngx.log(ngx.INFO, "tls header parsing error: ", err) 337 | break 338 | end 339 | ngx.log(ngx.INFO, "tls server_name: ", server_name) 340 | 341 | local upstream, port, flags = _select_upstream(server_name) 342 | 343 | if not upstream or not port then 344 | ngx.log(ngx.WARN, "no entries matching server_name: ", server_name) 345 | break 346 | end 347 | ngx.log(ngx.INFO, "selecting upstream: ", upstream, ":", port) 348 | local ok, err = self.srvsock:connect(upstream, port) 349 | if not ok then 350 | ngx.log(ngx.ERR, format("failed to connect to proxy upstream: %s:%s, err:%s", server_name, port, err)) 351 | break 352 | end 353 | 354 | -- send proxy protocol handshake (v1) 355 | if bit.band(flags, _M.SNI_PROXY_PROTOCOL_UPSTREAM) > 0 then 356 | local addr, port 357 | if bit.band(flags, _M.SNI_PROXY_PROTOCOL) > 0 then 358 | addr = ngx.var.proxy_protocol_addr 359 | port = ngx.var.proxy_protocol_port 360 | else 361 | addr = ngx.var.remote_addr 362 | port = ngx.var.remote_port 363 | end 364 | if #addr == 0 then -- unix domain? 365 | self.srvsock:send("PROXY UNKNOWN\r\n") 366 | else 367 | local typ 368 | if string.sub(addr, 1, 5) ~= "unix:" and string.find(addr, ":") then 369 | typ = "TCP6" 370 | else 371 | typ = "TCP4" 372 | end 373 | local srv_addr = ngx.var.server_addr 374 | local srv_port = ngx.var.server_port 375 | if string.sub(srv_addr, 1, 5) == "unix:" then 376 | srv_addr = "127.0.0.1" 377 | srv_port = 0 378 | end 379 | self.srvsock:send(string.format("PROXY %s %s %s %s %s\r\n", 380 | typ, 381 | addr, 382 | srv_addr, 383 | port, 384 | srv_port 385 | )) 386 | end 387 | end 388 | 389 | -- send tls headers 390 | self.srvsock:send(header) 391 | 392 | local co_upl = spawn(_upl, self) 393 | local co_dwn = spawn(_dwn, self) 394 | wait(co_upl) 395 | wait(co_dwn) 396 | 397 | break 398 | end 399 | 400 | -- make sure buffers are clean 401 | ngx.flush(true) 402 | 403 | local srvsock = self.srvsock 404 | local reqsock = self.reqsock 405 | if srvsock ~= nil then 406 | if srvsock.shutdown then 407 | srvsock:shutdown("send") 408 | end 409 | if srvsock.close ~= nil then 410 | local ok, err = srvsock:setkeepalive() 411 | if not ok then 412 | -- 413 | end 414 | end 415 | end 416 | 417 | if reqsock ~= nil then 418 | if reqsock.shutdown then 419 | reqsock:shutdown("send") 420 | end 421 | if reqsock.close ~= nil then 422 | local ok, err = reqsock:close() 423 | if not ok then 424 | -- 425 | end 426 | end 427 | end 428 | 429 | end 430 | 431 | -- backward compatibility 432 | function _M.run(self) 433 | local phase = ngx.get_phase() 434 | if phase == 'content' then 435 | ngx.log(ngx.ERR, "content_by") 436 | self:content_by() 437 | elseif phase == 'preread' then 438 | ngx.log(ngx.ERR, "preread_by") 439 | self:preread_by() 440 | else 441 | ngx.log(ngx.ERR, "sniproxy doesn't support running in ", phase) 442 | ngx.exit(ngx.ERROR) 443 | end 444 | end 445 | 446 | 447 | return _M 448 | -------------------------------------------------------------------------------- /lua-resty-sniproxy-0.22-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "lua-resty-sniproxy" 2 | version = "0.22-1" 3 | source = { 4 | url = "git+ssh://git@github.com/fffonion/lua-resty-sniproxy.git", 5 | tag = "0.22" 6 | } 7 | description = { 8 | summary = "lua-resty-sniproxy - SNI Proxy based on the ngx_lua cosocket API", 9 | detailed = "lua-resty-sniproxy - SNI Proxy based on the ngx_lua cosocket API", 10 | homepage = "https://github.com/fffonion/lua-resty-sniproxy", 11 | license = "BSD" 12 | } 13 | build = { 14 | type = "builtin", 15 | modules = { 16 | ["resty.sniproxy"] = "lib/resty/sniproxy.lua" 17 | } 18 | } 19 | --------------------------------------------------------------------------------